Skip to content

Commit 86008b1

Browse files
DAreRodzluisherranzDAreRodz
authored
iAPI: Introduce AsyncAction and TypeYield type helpers (#70422)
* Introduce AsyncAction and TypeYield helpers * Use the helpers on the other tests * Update docs for AsyncAction * Update docs for TypeYield * Export the helpers * Replace manual typing with satisfies on TypeYield return * Improve docs * Update changelog --------- Co-authored-by: luisherranz <[email protected]> Co-authored-by: DAreRodz <[email protected]>
1 parent 1411911 commit 86008b1

File tree

5 files changed

+131
-25
lines changed

5 files changed

+131
-25
lines changed

docs/reference-guides/interactivity-api/core-concepts/using-typescript.md

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -471,39 +471,84 @@ type Store = {
471471
};
472472
```
473473

474-
There's something to keep in mind when when using asynchronous actions. Just like with the derived state, if the asynchronous action needs to return a value and this value directly depends on some part of the global state, TypeScript will not be able to infer the type due to a circular reference.
474+
There's something to keep in mind when using asynchronous actions. Just like with the derived state, if an asynchronous action uses `state` within a `yield` expression (for example, by passing `state` to an async function that is then yielded) or if its return value depends on `state`, TypeScript might not be able to infer the types correctly due to a potential circular reference.
475475

476476
```ts
477477
const { state, actions } = store( 'myCounterPlugin', {
478478
state: {
479479
counter: 0,
480480
},
481481
actions: {
482-
*delayedReturn() {
483-
yield new Promise( ( r ) => setTimeout( r, 1000 ) );
484-
return state.counter; // TypeScript can't infer this return type.
482+
*delayedOperation() {
483+
// Example: state.counter is used as part of the yielded logic.
484+
yield fetchCounterData( state.counter );
485+
486+
// And/or the final return value depends on state.
487+
return state.counter + 1;
485488
},
486489
},
487490
} );
488491
```
489492

490-
In this case, just as we did with the derived state, we must manually type the return value of the generator.
493+
In such cases, TypeScript might issue a warning about a circular reference or default to `any`. To solve this, you need to manually type the generator function. The Interactivity API provides a helper type, `AsyncAction<ReturnType>`, for this purpose.
491494

492495
```ts
496+
import { store, type AsyncAction } from '@wordpress/interactivity';
497+
493498
const { state, actions } = store( 'myCounterPlugin', {
494499
state: {
495500
counter: 0,
496501
},
497502
actions: {
498-
*delayedReturn(): Generator< unknown, number, unknown > {
499-
yield new Promise( ( r ) => setTimeout( r, 1000 ) );
500-
return state.counter; // Now this is correctly inferred.
503+
*delayedOperation(): AsyncAction< number > {
504+
// Now, this doesn't cause a circular reference.
505+
yield fetchCounterData( state.counter );
506+
507+
// Now, this is correctly typed.
508+
return state.counter + 1;
509+
},
510+
},
511+
} );
512+
```
513+
514+
That's it! The `AsyncAction<ReturnType>` helper is defined as `Generator<any, ReturnType, unknown>`. By using `any` for the type of values yielded by the generator, it helps break the circular reference, allowing TypeScript to correctly infer the types when `state` is involved in `yield` expressions or in the final return value. You only need to specify the final `ReturnType` of your asynchronous action.
515+
516+
### Typing yielded values in asynchronous actions
517+
518+
While `AsyncAction<ReturnType>` types the overall generator and its final return value, the value resolved by an individual `yield` expression within that generator might still be typed as `any`.
519+
520+
If you need to ensure the correct type for a value that a `yield` expression resolves to (e.g., the result of a `fetch` call or another async operation), you can use the `TypeYield<T>` helper. This helper takes the type of the asynchronous function/operation being yielded (`T`) and resolves to the type of the value that the promise fulfills with.
521+
522+
Suppose `fetchCounterData` returns a promise that resolves to an object:
523+
524+
```ts
525+
import { store, type AsyncAction, type TypeYield } from '@wordpress/interactivity';
526+
527+
// Assume this function is defined elsewhere and fetches specific data.
528+
const fetchCounterData = async ( counterValue: number ): Promise< { current: number, next: number } > => {
529+
// internal logic...
530+
};
531+
532+
const { state, actions } = store( 'myCounterPlugin', {
533+
state: {
534+
counter: 0,
535+
},
536+
actions: {
537+
*loadCounterData(): AsyncAction< void > {
538+
// Use TypeYield to correctly type the resolved value of the yield.
539+
const data = ( yield fetchCounterData( state.counter ) ) as TypeYield< typeof fetchCounterData >;
540+
541+
// Now, `data` is correctly typed as { current: number, next: number }.
542+
console.log( data.current, data.next );
543+
544+
// Update state based on the fetched data.
545+
state.counter = data.next;
501546
},
502547
},
503548
} );
504549
```
505550

506-
That's it! Remember that the return type of a Generator is the second generic argument: `Generator< unknown, ReturnType, unknown >`.
551+
In this example, `( yield fetchCounterData( state.counter ) ) as TypeYield< typeof fetchCounterData >` ensures that the `data` constant is correctly typed as `{ current: number, next: number }`, matching the return type of `fetchCounterData`. This allows you to confidently access properties like `data.current` and `data.next` with type safety.
507552

508553
## Typing stores that are divided into multiple parts
509554

packages/interactivity-router/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Export `NavigateOptions` and `PrefetchOptions` types. ([#70315](https://github.com/WordPress/gutenberg/pull/70315))
88
- Support new styles and script modules on client-side navigation, including a new full-page client-side navigation mode. ([#70353](https://github.com/WordPress/gutenberg/pull/70353))
9+
- Introduce `AsyncAction` and `TypeYield` type helpers. ([#70422](https://github.com/WordPress/gutenberg/pull/70422))
910

1011
### Bug Fixes
1112

packages/interactivity/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import { getNamespace } from './namespaces';
1616
import { parseServerData, populateServerData } from './store';
1717
import { proxifyState } from './proxies';
1818

19-
export { store, getConfig, getServerState } from './store';
19+
export {
20+
store,
21+
getConfig,
22+
getServerState,
23+
type AsyncAction,
24+
type TypeYield,
25+
} from './store';
2026
export { getContext, getServerContext, getElement } from './scopes';
2127
export {
2228
withScope,

packages/interactivity/src/store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ interface StoreOptions {
8484
lock?: boolean | string;
8585
}
8686

87+
export type AsyncAction< T > = Generator< any, T, unknown >;
88+
export type TypeYield< T extends ( ...args: any[] ) => Promise< any > > =
89+
Awaited< ReturnType< T > >;
8790
type Prettify< T > = { [ K in keyof T ]: T[ K ] } & {};
8891
type DeepPartial< T > = T extends object
8992
? { [ P in keyof T ]?: DeepPartial< T[ P ] > }

packages/interactivity/src/test/store.ts

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Internal dependencies
33
*/
4-
import { store } from '../store';
4+
import { store, type AsyncAction, type TypeYield } from '../store';
55

66
describe( 'Interactivity API', () => {
77
describe( 'store', () => {
@@ -74,7 +74,7 @@ describe( 'Interactivity API', () => {
7474
sync( n ) {
7575
return n;
7676
},
77-
*async( n ): Generator< unknown, number, unknown > {
77+
*async( n ): AsyncAction< number > {
7878
const n1 = myStore.actions.sync( n );
7979
return myStore.state.derived + n1 + n;
8080
},
@@ -114,13 +114,17 @@ describe( 'Interactivity API', () => {
114114
sync( n: number ) {
115115
return n;
116116
},
117-
*async(
118-
n: number
119-
): Generator< unknown, number, number > {
120-
const n1: number =
121-
yield myStore.actions.sync( n );
117+
*async( n: number ): AsyncAction< number > {
118+
const n1 = ( yield myStore.actions.async2(
119+
n
120+
) ) as TypeYield<
121+
typeof myStore.actions.async2
122+
> satisfies number;
122123
return myStore.state.derived + n1 + n;
123124
},
125+
*async2( n: number ) {
126+
return n;
127+
},
124128
},
125129
};
126130

@@ -161,13 +165,17 @@ describe( 'Interactivity API', () => {
161165
sync( n: number ) {
162166
return n;
163167
},
164-
*async(
165-
n: number
166-
): Generator< unknown, number, number > {
167-
const n1: number =
168-
yield myStore.actions.sync( n );
168+
*async( n: number ): AsyncAction< number > {
169+
const n1 = ( yield myStore.actions.async2(
170+
n
171+
) ) as TypeYield<
172+
typeof myStore.actions.async2
173+
> satisfies number;
169174
return myStore.state.derived + n1 + n;
170175
},
176+
*async2( n: number ) {
177+
return n;
178+
},
171179
},
172180
} );
173181

@@ -191,6 +199,7 @@ describe( 'Interactivity API', () => {
191199
actions: {
192200
sync: ( n: number ) => number;
193201
async: ( n: number ) => Promise< number >;
202+
async2: ( n: number ) => AsyncAction< number >;
194203
};
195204
callbacks: {
196205
existent: number;
@@ -202,11 +211,17 @@ describe( 'Interactivity API', () => {
202211
sync( n ) {
203212
return n;
204213
},
205-
*async( n ): Generator< unknown, number, number > {
206-
const n1: number =
207-
yield myStore.actions.sync( n );
214+
*async( n ): AsyncAction< number > {
215+
const n1 = ( yield myStore.actions.async2(
216+
n
217+
) ) as TypeYield<
218+
typeof myStore.actions.async2
219+
> satisfies number;
208220
return n1 + n;
209221
},
222+
*async2( n: number ) {
223+
return n;
224+
},
210225
},
211226
callbacks: {
212227
existent: 1,
@@ -317,6 +332,42 @@ describe( 'Interactivity API', () => {
317332

318333
actions2.incrementValue( 1 ) satisfies void;
319334
} );
335+
336+
describe( 'async actions can pass state to yields and type the yield returns', () => {
337+
// eslint-disable-next-line no-unused-expressions
338+
async () => {
339+
type Store = {
340+
state: {
341+
someValue: string;
342+
};
343+
actions: {
344+
asyncAction: () => Promise< number >;
345+
};
346+
};
347+
348+
const asyncFunction = async (
349+
someValue: string
350+
): Promise< string > => {
351+
return someValue;
352+
};
353+
354+
const { state, actions } = store< Store >( 'test', {
355+
actions: {
356+
*asyncAction(): AsyncAction< number > {
357+
( yield asyncFunction(
358+
state.someValue
359+
) ) as TypeYield<
360+
typeof asyncFunction
361+
> satisfies string;
362+
363+
return 1;
364+
},
365+
},
366+
} );
367+
368+
( await actions.asyncAction() ) satisfies number;
369+
};
370+
} );
320371
} );
321372
} );
322373
} );

0 commit comments

Comments
 (0)