Skip to content

Commit 37b46e4

Browse files
committed
feat(core): pass invalidate info to async signals
1 parent 27423ce commit 37b46e4

8 files changed

Lines changed: 146 additions & 15 deletions

File tree

.changeset/two-years-teach.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': minor
3+
---
4+
5+
FEAT: `asyncSignal.invalidate(info: unknown)` allows passing `info` to the calculation function. This can for example be used to request cache busting while reloading.

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@
261261
}
262262
],
263263
"kind": "Interface",
264-
"content": "An AsyncSignal 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\nIf the async function threw an error, reading the `.value` will throw that same error. Read from `.error` to check if there was an error.\n\n\n```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\nerror\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\ninterval\n\n\n</td><td>\n\n\n</td><td>\n\nnumber\n\n\n</td><td>\n\nStaleness/poll interval in ms. Writable and immediately effective.\n\n- \\*\\*Positive\\*\\*: Poll — re-compute after this many ms when subscribers exist. - \\*\\*Negative\\*\\*: Stale-only — mark stale after `|interval|` ms, no auto-recompute. - \\*\\*`0`<!-- -->\\*\\*: No staleness tracking or polling.\n\n\n</td></tr>\n<tr><td>\n\nloading\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</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(reason)](#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>",
264+
"content": "An AsyncSignal 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\nIf the async function threw an error, reading the `.value` will throw that same error. Read from `.error` to check if there was an error.\n\n\n```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\nerror\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\ninterval\n\n\n</td><td>\n\n\n</td><td>\n\nnumber\n\n\n</td><td>\n\nStaleness/poll interval in ms. Writable and immediately effective.\n\n- \\*\\*Positive\\*\\*: Poll — re-compute after this many ms when subscribers exist. - \\*\\*Negative\\*\\*: Stale-only — mark stale after `|interval|` ms, no auto-recompute. - \\*\\*`0`<!-- -->\\*\\*: No staleness tracking or polling.\n\n\n</td></tr>\n<tr><td>\n\nloading\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</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(reason)](#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[invalidate(info)](#asyncsignal-invalidate)\n\n\n</td><td>\n\nUse this to force recalculation. If you pass `info`<!-- -->, it will be provided to the calculation function.\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>",
265265
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts",
266266
"mdFile": "core.asyncsignal.md"
267267
},
@@ -421,7 +421,7 @@
421421
}
422422
],
423423
"kind": "Interface",
424-
"content": "A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.\n\n\n```typescript\nexport interface ComputedSignal<T> extends Signal<T> \n```\n**Extends:** [Signal](#signal)<!-- -->&lt;T&gt;\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[force()](#computedsignal-force)\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[invalidate()](#computedsignal-invalidate)\n\n\n</td><td>\n\nUse this to force recalculation.\n\n\n</td></tr>\n</tbody></table>",
424+
"content": "A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.\n\n\n```typescript\nexport interface ComputedSignal<T> extends Signal<T> \n```\n**Extends:** [Signal](#signal)<!-- -->&lt;T&gt;\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[force()](#computedsignal-force)\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\ninvalidate()\n\n\n</td><td>\n\nUse this to force recalculation.\n\n\n</td></tr>\n</tbody></table>",
425425
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts",
426426
"mdFile": "core.computedsignal.md"
427427
},
@@ -879,20 +879,20 @@
879879
},
880880
{
881881
"name": "invalidate",
882-
"id": "computedsignal-invalidate",
882+
"id": "asyncsignal-invalidate",
883883
"hierarchy": [
884884
{
885-
"name": "ComputedSignal",
886-
"id": "computedsignal-invalidate"
885+
"name": "AsyncSignal",
886+
"id": "asyncsignal-invalidate"
887887
},
888888
{
889889
"name": "invalidate",
890-
"id": "computedsignal-invalidate"
890+
"id": "asyncsignal-invalidate"
891891
}
892892
],
893893
"kind": "MethodSignature",
894-
"content": "Use this to force recalculation.\n\n\n```typescript\ninvalidate(): void;\n```\n**Returns:**\n\nvoid",
895-
"mdFile": "core.computedsignal.invalidate.md"
894+
"content": "Use this to force recalculation. If you pass `info`<!-- -->, it will be provided to the calculation function.\n\n\n```typescript\ninvalidate(info?: unknown): void;\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\ninfo\n\n\n</td><td>\n\nunknown\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\nvoid",
895+
"mdFile": "core.asyncsignal.invalidate.md"
896896
},
897897
{
898898
"name": "isSignal",

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

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,15 @@ Abort the current computation and run cleanups if needed.
288288
</td></tr>
289289
<tr><td>
290290
291+
[invalidate(info)](#asyncsignal-invalidate)
292+
293+
</td><td>
294+
295+
Use this to force recalculation. If you pass `info`, it will be provided to the calculation function.
296+
297+
</td></tr>
298+
<tr><td>
299+
291300
[promise()](#asyncsignal-promise)
292301
293302
</td><td>
@@ -783,7 +792,7 @@ Description
783792
</td></tr>
784793
<tr><td>
785794
786-
[invalidate()](#computedsignal-invalidate)
795+
invalidate()
787796
788797
</td><td>
789798
@@ -2032,14 +2041,42 @@ interface IntrinsicElements extends LenientQwikElements
20322041
20332042
**Extends:** LenientQwikElements
20342043
2035-
<h2 id="computedsignal-invalidate">invalidate</h2>
2044+
<h2 id="asyncsignal-invalidate">invalidate</h2>
20362045
2037-
Use this to force recalculation.
2046+
Use this to force recalculation. If you pass `info`, it will be provided to the calculation function.
20382047
20392048
```typescript
2040-
invalidate(): void;
2049+
invalidate(info?: unknown): void;
20412050
```
20422051
2052+
<table><thead><tr><th>
2053+
2054+
Parameter
2055+
2056+
</th><th>
2057+
2058+
Type
2059+
2060+
</th><th>
2061+
2062+
Description
2063+
2064+
</th></tr></thead>
2065+
<tbody><tr><td>
2066+
2067+
info
2068+
2069+
</td><td>
2070+
2071+
unknown
2072+
2073+
</td><td>
2074+
2075+
_(Optional)_
2076+
2077+
</td></tr>
2078+
</tbody></table>
2079+
20432080
**Returns:**
20442081
20452082
void

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface AsyncSignal<T = unknown> extends ComputedSignal<T> {
2626
abort(reason?: any): void;
2727
error: Error | undefined;
2828
interval: number;
29+
invalidate(info?: unknown): void;
2930
loading: boolean;
3031
promise(): Promise<void>;
3132
}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ class AsyncJob<T> implements AsyncCtx<T> {
4343
$cleanups$: Parameters<AsyncCtx<T>['cleanup']>[0][] | undefined;
4444
$abortController$: AbortController | undefined;
4545

46-
constructor(readonly $signal$: AsyncSignalImpl<T>) {}
46+
constructor(
47+
readonly $signal$: AsyncSignalImpl<T>,
48+
readonly info: unknown,
49+
readonly $infoVersion$: number
50+
) {}
4751

4852
get track(): AsyncCtx<T>['track'] {
4953
return (this.$track$ ||= trackFn(this.$signal$, this.$signal$.$container$));
@@ -97,6 +101,8 @@ export class AsyncSignalImpl<T>
97101
$concurrency$: number = 1;
98102
$interval$: number = 0;
99103
$timeoutMs$: number | undefined;
104+
$info$: unknown = undefined;
105+
$infoVersion$: number = 0;
100106
declare $pollTimeoutId$: ReturnType<typeof setTimeout> | undefined;
101107
declare $computationTimeoutId$: ReturnType<typeof setTimeout> | undefined;
102108

@@ -265,9 +271,13 @@ export class AsyncSignalImpl<T>
265271
}
266272

267273
/** Invalidates the signal, causing it to re-compute its value. */
268-
override async invalidate() {
274+
override async invalidate(info?: unknown) {
269275
this.$flags$ |= SignalFlags.INVALID;
270276
this.$clearNextPoll$();
277+
if (arguments.length > 0) {
278+
this.$info$ = info;
279+
this.$infoVersion$++;
280+
}
271281
if (this.$effects$?.size || this.$loadingEffects$?.size || this.$errorEffects$?.size) {
272282
// compute in next microtask
273283
await true;
@@ -339,7 +349,8 @@ export class AsyncSignalImpl<T>
339349
this.$flags$ &= ~SignalFlags.INVALID;
340350

341351
// We put the actual computation in a separate method so we can easily retain the promise
342-
const running = new AsyncJob(this);
352+
const infoVersion = this.$infoVersion$;
353+
const running = new AsyncJob(this, this.$info$, infoVersion);
343354
this.$current$ = running;
344355
this.$jobs$.push(running);
345356
running.$promise$ = this.$runComputation$(running);
@@ -393,6 +404,9 @@ export class AsyncSignalImpl<T>
393404

394405
if (isCurrent()) {
395406
clearTimeout(this.$computationTimeoutId$);
407+
if (running.$infoVersion$ === this.$infoVersion$) {
408+
this.$info$ = undefined;
409+
}
396410

397411
if (this.$flags$ & SignalFlags.INVALID) {
398412
DEBUG && log('Computation finished but signal is invalid, re-running');

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ describe('signal types', () => {
125125
expectTypeOf(signal.untrackedValue).toEqualTypeOf<number>();
126126
expectTypeOf(signal.abort()).toEqualTypeOf<void>();
127127
expectTypeOf(signal.invalidate()).toEqualTypeOf<void>();
128+
expectTypeOf(signal.invalidate('info')).toEqualTypeOf<void>();
128129
});
129130
});
130131

@@ -333,6 +334,72 @@ describe('signal', () => {
333334
});
334335
});
335336

337+
it('should expose invalidate info to the next computation', async () => {
338+
await withContainer(async () => {
339+
const infos: unknown[] = [];
340+
const signal = createAsync$(
341+
async ({ info }) => {
342+
infos.push(info);
343+
return infos.length;
344+
},
345+
{ initial: 0 }
346+
) as AsyncSignalImpl<number>;
347+
348+
effect$(() => signal.value);
349+
await signal.promise();
350+
351+
signal.invalidate('refresh');
352+
await signal.promise();
353+
354+
expect(infos).toEqual([undefined, 'refresh']);
355+
});
356+
});
357+
358+
it('should reset invalidate info after computation completes', async () => {
359+
await withContainer(async () => {
360+
const infos: unknown[] = [];
361+
const signal = createAsync$(
362+
async ({ info }) => {
363+
infos.push(info);
364+
return infos.length;
365+
},
366+
{ initial: 0 }
367+
) as AsyncSignalImpl<number>;
368+
369+
effect$(() => signal.value);
370+
await signal.promise();
371+
372+
signal.invalidate(true);
373+
await signal.promise();
374+
signal.invalidate();
375+
await signal.promise();
376+
377+
expect(infos).toEqual([undefined, true, undefined]);
378+
});
379+
});
380+
381+
it('should use the latest invalidate info before recalculation starts', async () => {
382+
await withContainer(async () => {
383+
const infos: unknown[] = [];
384+
const signal = createAsync$(
385+
async ({ info }) => {
386+
infos.push(info);
387+
return infos.length;
388+
},
389+
{ initial: 0 }
390+
) as AsyncSignalImpl<number>;
391+
392+
effect$(() => signal.value);
393+
await signal.promise();
394+
395+
signal.invalidate('first');
396+
signal.invalidate('second');
397+
await signal.promise();
398+
399+
expect(infos).toEqual([undefined, 'second']);
400+
});
401+
});
402+
336403
it('should poll', async () => {
337404
await withContainer(async () => {
338405
const interval = 1;

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export interface AsyncSignal<T = unknown> extends ComputedSignal<T> {
114114
promise(): Promise<void>;
115115
/** Abort the current computation and run cleanups if needed. */
116116
abort(reason?: any): void;
117+
/**
118+
* Use this to force recalculation. If you pass `info`, it will be provided to the calculation
119+
* function.
120+
*/
121+
invalidate(info?: unknown): void;
117122
}
118123

119124
/**

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type AsyncCtx<T = unknown> = {
4141
readonly abortSignal: AbortSignal;
4242
/** The result of the previous computation, if any */
4343
readonly previous: T | undefined;
44+
/** Extra info passed to `invalidate(info)` for this computation, if any. */
45+
readonly info: unknown;
4446
};
4547
export type AsyncQRL<T> = QRLInternal<AsyncFn<T>>;
4648

0 commit comments

Comments
 (0)