repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

commit
95bee2f
parent
797ba54
author
Eric Bower
date
2024-02-10 16:59:03 +0000 UTC
Wait for loader (#37)

13 files changed,  +241, -110
M action.ts
+25, -0
 1@@ -1,5 +1,6 @@
 2 import {
 3   call,
 4+  Callable,
 5   createContext,
 6   createSignal,
 7   each,
 8@@ -12,6 +13,7 @@ import {
 9 import { ActionPattern, matcher } from "./matcher.ts";
10 import type { Action, ActionWithPayload, AnyAction } from "./types.ts";
11 import { createFilterQueue } from "./queue.ts";
12+import { ActionFnWithPayload } from "./types.ts";
13 
14 export const ActionContext = createContext(
15   "starfx:action",
16@@ -103,6 +105,29 @@ export function* takeLeading<T>(
17   }
18 }
19 
20+export function* waitFor(
21+  predicate: Callable<boolean>,
22+) {
23+  const init = yield* call(predicate as any);
24+  if (init) {
25+    return;
26+  }
27+
28+  while (true) {
29+    yield* take("*");
30+    const result = yield* call(() => predicate as any);
31+    if (result) {
32+      return;
33+    }
34+  }
35+}
36+
37+export function getIdFromAction(
38+  action: ActionWithPayload<{ key: string }> | ActionFnWithPayload,
39+): string {
40+  return typeof action === "function" ? action.toString() : action.payload.key;
41+}
42+
43 export const API_ACTION_PREFIX = "";
44 export const createAction = (type: string) => {
45   if (!type) throw new Error("createAction requires non-empty string");
M fx/supervisor.ts
+1, -1
1@@ -11,7 +11,7 @@ export function superviseBackoff(attempt: number, max = 10): number {
2 }
3 
4 /**
5- * {@link supvervise} will watch whatever {@link Operation} is provided
6+ * supvervise will watch whatever {@link Operation} is provided
7  * and it will automatically try to restart it when it exists.  By
8  * default it uses a backoff pressure mechanism so if there is an
9  * error simply calling the {@link Operation} then it will exponentially
M query/mod.ts
+2, -1
 1@@ -1,8 +1,9 @@
 2 import { createThunks, type ThunksApi } from "./thunk.ts";
 3+import * as mdw from "./mdw.ts";
 4+
 5 export * from "./api.ts";
 6 export * from "./types.ts";
 7 export * from "./create-key.ts";
 8-import * as mdw from "./mdw.ts";
 9 
10 export { createThunks, mdw, ThunksApi };
11 
M react.ts
+8, -9
 1@@ -7,6 +7,8 @@ import {
 2 import type { AnyState, LoaderState } from "./types.ts";
 3 import type { ThunkAction } from "./query/mod.ts";
 4 import { type FxSchema, type FxStore, PERSIST_LOADER_ID } from "./store/mod.ts";
 5+import { ActionFn, ActionFnWithPayload } from "./types.ts";
 6+import { getIdFromAction } from "./action.ts";
 7 
 8 export { useDispatch, useSelector } from "./deps.ts";
 9 export type { TypedUseSelectorHook } from "./deps.ts";
10@@ -19,16 +21,13 @@ const {
11   createElement: h,
12 } = React;
13 
14-type ActionFn<P = any> = (p: P) => { toString: () => string };
15-type ActionFnSimple = () => { toString: () => string };
16-
17 export interface UseApiProps<P = any> extends LoaderState {
18   trigger: (p: P) => void;
19-  action: ActionFn<P>;
20+  action: ActionFnWithPayload<P>;
21 }
22 export interface UseApiSimpleProps extends LoaderState {
23   trigger: () => void;
24-  action: ActionFn;
25+  action: ActionFnWithPayload;
26 }
27 export interface UseApiAction<A extends ThunkAction = ThunkAction>
28   extends LoaderState {
29@@ -93,10 +92,10 @@ export function useSchema<S extends AnyState>() {
30  * ```
31  */
32 export function useLoader<S extends AnyState>(
33-  action: ThunkAction | ActionFn,
34+  action: ThunkAction | ActionFnWithPayload,
35 ) {
36   const schema = useSchema();
37-  const id = typeof action === "function" ? `${action}` : action.payload.key;
38+  const id = getIdFromAction(action);
39   return useSelector((s: S) => schema.loaders.selectById(s, { id }));
40 }
41 
42@@ -131,10 +130,10 @@ export function useApi<P = any, A extends ThunkAction = ThunkAction<P>>(
43   action: A,
44 ): UseApiAction<A>;
45 export function useApi<P = any, A extends ThunkAction = ThunkAction<P>>(
46-  action: ActionFn<P>,
47+  action: ActionFnWithPayload<P>,
48 ): UseApiProps<P>;
49 export function useApi<A extends ThunkAction = ThunkAction>(
50-  action: ActionFnSimple,
51+  action: ActionFn,
52 ): UseApiSimpleProps;
53 export function useApi(action: any): any {
54   const dispatch = useDispatch();
M store/fx.ts
+36, -2
 1@@ -1,9 +1,11 @@
 2 import { Operation, Result } from "../deps.ts";
 3-import type { AnyState } from "../types.ts";
 4+import type { ActionFnWithPayload, AnyState } from "../types.ts";
 5 import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.ts";
 6 import { StoreContext } from "./context.ts";
 7 import { LoaderOutput } from "./slice/loader.ts";
 8-import { safe } from "../fx/mod.ts";
 9+import { parallel, safe } from "../fx/mod.ts";
10+import { ThunkAction } from "../query/mod.ts";
11+import { getIdFromAction, take } from "../action.ts";
12 
13 export function* updateStore<S extends AnyState>(
14   updater: StoreUpdater<S> | StoreUpdater<S>[],
15@@ -28,6 +30,38 @@ export function* select<S, R, P>(
16   return selectorFn(store.getState() as S, p);
17 }
18 
19+export function* waitForLoader<M extends AnyState>(
20+  loaders: LoaderOutput<M, AnyState>,
21+  action: ThunkAction | ActionFnWithPayload,
22+) {
23+  const id = getIdFromAction(action);
24+  const selector = (s: AnyState) => loaders.selectById(s, { id });
25+
26+  // check for done state on init
27+  let loader = yield* select(selector);
28+  if (loader.isSuccess || loader.isError) {
29+    return loader;
30+  }
31+
32+  while (true) {
33+    yield* take("*");
34+    loader = yield* select(selector);
35+    if (loader.isSuccess || loader.isError) {
36+      return loader;
37+    }
38+  }
39+}
40+
41+export function* waitForLoaders<M extends AnyState>(
42+  loaders: LoaderOutput<M, AnyState>,
43+  actions: (ThunkAction | ActionFnWithPayload)[],
44+) {
45+  const group = yield* parallel(
46+    actions.map((action) => waitForLoader(loaders, action)),
47+  );
48+  return yield* group;
49+}
50+
51 export function createTracker<T, M extends Record<string, unknown>>(
52   loader: LoaderOutput<M, AnyState>,
53 ) {
M store/query.ts
+1, -1
1@@ -128,7 +128,7 @@ export function loaderApi<
2     }
3 
4     if (!ctx.loader) {
5-      ctx.loader || {};
6+      ctx.loader = {};
7     }
8 
9     if (!ctx.response.ok) {
M test.ts
+0, -7
 1@@ -8,13 +8,6 @@ export * as asserts from "https://deno.land/std@0.185.0/testing/asserts.ts";
 2 export { expect } from "https://deno.land/x/expect@v0.3.0/mod.ts";
 3 export { install, mock } from "https://deno.land/x/mock_fetch@0.3.0/mod.ts";
 4 
 5-export const sleep = (n: number) =>
 6-  new Promise<void>((resolve) => {
 7-    setTimeout(() => {
 8-      resolve();
 9-    }, n);
10-  });
11-
12 export function isLikeSelector(selector: unknown) {
13   return (
14     selector !== null &&
M test/api.test.ts
+14, -11
 1@@ -1,21 +1,25 @@
 2 import { describe, expect, it } from "../test.ts";
 3-import { sleep } from "../test.ts";
 4 import {
 5   configureStore,
 6   createSchema,
 7+  select,
 8   slice,
 9   storeMdw,
10   updateStore,
11+  waitForLoader,
12 } from "../store/mod.ts";
13 import {
14+  AnyState,
15   type ApiCtx,
16   call,
17   createApi,
18   createKey,
19   keepAlive,
20   mdw,
21+  Operation,
22   safe,
23   takeEvery,
24+  waitFor,
25 } from "../mod.ts";
26 import { useCache } from "../react.ts";
27 
28@@ -44,9 +48,8 @@ const jsonBlob = (data: unknown) => {
29 
30 const tests = describe("createApi()");
31 
32-it(tests, "createApi - POST", async () => {
33+it(tests, "POST", async () => {
34   const query = createApi();
35-
36   query.use(mdw.queryCtx);
37   query.use(mdw.nameParser);
38   query.use(query.routes());
39@@ -102,7 +105,12 @@ it(tests, "createApi - POST", async () => {
40   store.run(query.bootup);
41 
42   store.dispatch(createUser({ email: mockUser.email }));
43-  await sleep(150);
44+
45+  await store.run(waitFor(function* (): Operation<boolean> {
46+    const res = yield* select((state: AnyState) => state.users["1"].id);
47+    return res !== "";
48+  }));
49+
50   expect(store.getState().users).toEqual({
51     "1": { id: "1", name: "test", email: "test@test.com" },
52   });
53@@ -292,7 +300,7 @@ it(tests, "with hash key on a large post", async () => {
54   const action = createUserDefaultKey({ email, largetext });
55   store.dispatch(action);
56 
57-  await sleep(150);
58+  await store.run(waitForLoader(schema.loaders, action));
59 
60   const s = store.getState();
61   const expectedKey = createKey(action.payload.name, {
62@@ -306,18 +314,16 @@ it(tests, "with hash key on a large post", async () => {
63   });
64 });
65 
66-it(tests, "createApi - two identical endpoints", async () => {
67+it(tests, "two identical endpoints", () => {
68   const actual: string[] = [];
69   const { store, schema } = testStore();
70   const api = createApi();
71   api.use(mdw.api());
72   api.use(storeMdw.store(schema));
73-  api.use(mdw.nameParser);
74   api.use(api.routes());
75 
76   const first = api.get(
77     "/health",
78-    { supervisor: takeEvery },
79     function* (ctx, next) {
80       actual.push(ctx.req().url);
81       yield* next();
82@@ -326,7 +332,6 @@ it(tests, "createApi - two identical endpoints", async () => {
83 
84   const second = api.get(
85     ["/health", "poll"],
86-    { supervisor: takeEvery },
87     function* (ctx, next) {
88       actual.push(ctx.req().url);
89       yield* next();
90@@ -337,8 +342,6 @@ it(tests, "createApi - two identical endpoints", async () => {
91   store.dispatch(first());
92   store.dispatch(second());
93 
94-  await sleep(150);
95-
96   expect(actual).toEqual(["/health", "/health"]);
97 });
98 
M test/fetch.test.ts
+117, -63
  1@@ -1,17 +1,19 @@
  2 import { describe, expect, install, it, mock } from "../test.ts";
  3-import { configureStore, createSchema, slice, storeMdw } from "../store/mod.ts";
  4-import { createApi, mdw, takeEvery } from "../mod.ts";
  5+import {
  6+  configureStore,
  7+  createSchema,
  8+  slice,
  9+  storeMdw,
 10+  waitForLoader,
 11+  waitForLoaders,
 12+} from "../store/mod.ts";
 13+import { ApiCtx, createApi, mdw, takeEvery } from "../mod.ts";
 14 
 15 install();
 16 
 17 const baseUrl = "https://starfx.com";
 18 const mockUser = { id: "1", email: "test@starfx.com" };
 19 
 20-const delay = (n = 200) =>
 21-  new Promise((resolve) => {
 22-    setTimeout(resolve, n);
 23-  });
 24-
 25 const testStore = () => {
 26   const [schema, initialState] = createSchema({
 27     loaders: slice.loader(),
 28@@ -21,6 +23,10 @@ const testStore = () => {
 29   return { schema, store };
 30 };
 31 
 32+const getTestData = (ctx: ApiCtx) => {
 33+  return { request: { ...ctx.req() }, json: { ...ctx.json } };
 34+};
 35+
 36 const tests = describe("mdw.fetch()");
 37 
 38 it(
 39@@ -57,11 +63,10 @@ it(
 40     const action = fetchUsers();
 41     store.dispatch(action);
 42 
 43-    await delay();
 44+    await store.run(waitForLoader(schema.loaders, action));
 45 
 46     const state = store.getState();
 47     expect(state.cache[action.payload.key]).toEqual(mockUser);
 48-
 49     expect(actual).toEqual([{
 50       url: `${baseUrl}/users`,
 51       method: "GET",
 52@@ -104,7 +109,8 @@ it(
 53     const action = fetchUsers();
 54     store.dispatch(action);
 55 
 56-    await delay();
 57+    await store.run(waitForLoader(schema.loaders, action));
 58+
 59     const data = "this is some text";
 60     expect(actual).toEqual({ ok: true, data, value: data });
 61   },
 62@@ -140,7 +146,7 @@ it(tests, "error handling", async () => {
 63   const action = fetchUsers();
 64   store.dispatch(action);
 65 
 66-  await delay();
 67+  await store.run(waitForLoader(schema.loaders, action));
 68 
 69   const state = store.getState();
 70   expect(state.cache[action.payload.key]).toEqual(errMsg);
 71@@ -180,7 +186,7 @@ it(tests, "status 204", async () => {
 72   const action = fetchUsers();
 73   store.dispatch(action);
 74 
 75-  await delay();
 76+  await store.run(waitForLoader(schema.loaders, action));
 77 
 78   const state = store.getState();
 79   expect(state.cache[action.payload.key]).toEqual({});
 80@@ -220,7 +226,7 @@ it(tests, "malformed json", async () => {
 81   const action = fetchUsers();
 82   store.dispatch(action);
 83 
 84-  await delay();
 85+  await store.run(waitForLoader(schema.loaders, action));
 86 
 87   const data = {
 88     message: "Unexpected token 'o', \"not json\" is not valid JSON",
 89@@ -255,16 +261,7 @@ it(tests, "POST", async () => {
 90       });
 91       yield* next();
 92 
 93-      expect(ctx.req()).toEqual({
 94-        url: `${baseUrl}/users`,
 95-        headers: {
 96-          "Content-Type": "application/json",
 97-        },
 98-        method: "POST",
 99-        body: JSON.stringify(mockUser),
100-      });
101-
102-      expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser });
103+      ctx.loader = { meta: getTestData(ctx) };
104     },
105   );
106 
107@@ -272,7 +269,25 @@ it(tests, "POST", async () => {
108   const action = fetchUsers();
109   store.dispatch(action);
110 
111-  await delay();
112+  const loader = await store.run(waitForLoader(schema.loaders, action));
113+  if (!loader.ok) {
114+    throw loader.error;
115+  }
116+
117+  expect(loader.value.meta.request).toEqual({
118+    url: `${baseUrl}/users`,
119+    headers: {
120+      "Content-Type": "application/json",
121+    },
122+    method: "POST",
123+    body: JSON.stringify(mockUser),
124+  });
125+
126+  expect(loader.value.meta.json).toEqual({
127+    ok: true,
128+    data: mockUser,
129+    value: mockUser,
130+  });
131 });
132 
133 it(tests, "POST multiple endpoints with same uri", async () => {
134@@ -296,16 +311,7 @@ it(tests, "POST multiple endpoints with same uri", async () => {
135       ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
136       yield* next();
137 
138-      expect(ctx.req()).toEqual({
139-        url: `${baseUrl}/users/1/something`,
140-        headers: {
141-          "Content-Type": "application/json",
142-        },
143-        method: "POST",
144-        body: JSON.stringify(mockUser),
145-      });
146-
147-      expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser });
148+      ctx.loader = { meta: getTestData(ctx) };
149     },
150   );
151 
152@@ -316,38 +322,74 @@ it(tests, "POST multiple endpoints with same uri", async () => {
153       ctx.cache = true;
154       ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
155       yield* next();
156-
157-      expect(ctx.req()).toEqual({
158-        url: `${baseUrl}/users/1/something`,
159-        headers: {
160-          "Content-Type": "application/json",
161-        },
162-        method: "POST",
163-        body: JSON.stringify(mockUser),
164-      });
165-
166-      expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser });
167+      ctx.loader = { meta: getTestData(ctx) };
168     },
169   );
170 
171   store.run(api.bootup);
172 
173-  store.dispatch(fetchUsers({ id: "1" }));
174-  store.dispatch(fetchUsersSecond({ id: "1" }));
175+  const action1 = fetchUsers({ id: "1" });
176+  const action2 = fetchUsersSecond({ id: "1" });
177+  store.dispatch(action1);
178+  store.dispatch(action2);
179+
180+  const results = await store.run(
181+    waitForLoaders(schema.loaders, [action1, action2]),
182+  );
183+  if (!results.ok) {
184+    throw results.error;
185+  }
186+  const result1 = results.value[0];
187+  if (!result1.ok) {
188+    throw result1.error;
189+  }
190+  const result2 = results.value[1];
191+  if (!result2.ok) {
192+    throw result2.error;
193+  }
194+
195+  expect(result1.value.meta.request).toEqual({
196+    url: `${baseUrl}/users/1/something`,
197+    headers: {
198+      "Content-Type": "application/json",
199+    },
200+    method: "POST",
201+    body: JSON.stringify(mockUser),
202+  });
203+
204+  expect(result1.value.meta.json).toEqual({
205+    ok: true,
206+    data: mockUser,
207+    value: mockUser,
208+  });
209 
210-  await delay();
211+  expect(result2.value.meta.request).toEqual({
212+    url: `${baseUrl}/users/1/something`,
213+    headers: {
214+      "Content-Type": "application/json",
215+    },
216+    method: "POST",
217+    body: JSON.stringify(mockUser),
218+  });
219+
220+  expect(result2.value.meta.json).toEqual({
221+    ok: true,
222+    data: mockUser,
223+    value: mockUser,
224+  });
225 });
226 
227 it(
228   tests,
229   "slug in url but payload has empty string for slug value",
230-  async () => {
231+  () => {
232     const { store, schema } = testStore();
233     const api = createApi();
234     api.use(mdw.api());
235     api.use(storeMdw.store(schema));
236     api.use(api.routes());
237     api.use(mdw.fetch({ baseUrl }));
238+    let actual = "";
239 
240     const fetchUsers = api.post<{ id: string }>(
241       "/users/:id",
242@@ -357,14 +399,9 @@ it(
243         ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
244 
245         yield* next();
246-
247-        const data =
248-          "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()";
249-        expect(ctx.json).toEqual({
250-          ok: false,
251-          data,
252-          error: data,
253-        });
254+        if (!ctx.json.ok) {
255+          actual = ctx.json.error;
256+        }
257       },
258     );
259 
260@@ -372,7 +409,9 @@ it(
261     const action = fetchUsers({ id: "" });
262     store.dispatch(action);
263 
264-    await delay();
265+    const data =
266+      "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()";
267+    expect(actual).toEqual(data);
268   },
269 );
270 
271@@ -418,7 +457,10 @@ it(
272     const action = fetchUsers();
273     store.dispatch(action);
274 
275-    await delay();
276+    const loader = await store.run(waitForLoader(schema.loaders, action));
277+    if (!loader.ok) {
278+      throw loader.error;
279+    }
280 
281     const state = store.getState();
282     expect(state.cache[action.payload.key]).toEqual(mockUser);
283@@ -457,7 +499,10 @@ it(
284     const action = fetchUsers();
285     store.dispatch(action);
286 
287-    await delay();
288+    const loader = await store.run(waitForLoader(schema.loaders, action));
289+    if (!loader.ok) {
290+      throw loader.error;
291+    }
292     const data = { message: "error" };
293     expect(actual).toEqual({ ok: false, data, error: data });
294   },
295@@ -486,7 +531,10 @@ it(
296     store.run(api.bootup);
297     store.dispatch(fetchUsers());
298 
299-    await delay();
300+    const loader = await store.run(waitForLoader(schema.loaders, fetchUsers));
301+    if (!loader.ok) {
302+      throw loader.error;
303+    }
304     expect(actual).toEqual({ ok: true, data: mockUser, value: mockUser });
305   },
306 );
307@@ -514,12 +562,18 @@ it(tests, "should use dynamic mdw to mock response", async () => {
308   const dynamicUser = { id: "2", email: "dynamic@starfx.com" };
309   fetchUsers.use(mdw.response(new Response(JSON.stringify(dynamicUser))));
310   store.dispatch(fetchUsers());
311-  await delay();
312+  let loader = await store.run(waitForLoader(schema.loaders, fetchUsers));
313+  if (!loader.ok) {
314+    throw loader.error;
315+  }
316   expect(actual).toEqual({ ok: true, data: dynamicUser, value: dynamicUser });
317 
318   // reset dynamic mdw and try again
319   api.reset();
320   store.dispatch(fetchUsers());
321-  await delay();
322+  loader = await store.run(waitForLoader(schema.loaders, fetchUsers));
323+  if (!loader.ok) {
324+    throw loader.error;
325+  }
326   expect(actual).toEqual({ ok: true, data: mockUser, value: mockUser });
327 });
M test/mdw.test.ts
+15, -6
 1@@ -1,18 +1,21 @@
 2-import { assertLike, asserts, describe, expect, it, sleep } from "../test.ts";
 3+import { assertLike, asserts, describe, expect, it } from "../test.ts";
 4 import {
 5   configureStore,
 6   createSchema,
 7   slice,
 8   storeMdw,
 9   updateStore,
10+  waitForLoader,
11 } from "../store/mod.ts";
12 import {
13   createApi,
14   createKey,
15   mdw,
16+  put,
17   safe,
18   takeEvery,
19   takeLatest,
20+  waitFor,
21 } from "../mod.ts";
22 import type { ApiCtx, Next, ThunkCtx } from "../mod.ts";
23 
24@@ -405,7 +408,9 @@ it(tests, "createApi with own key", async () => {
25   store.run(query.bootup);
26 
27   store.dispatch(createUserCustomKey({ email: newUEmail }));
28-  await sleep(150);
29+
30+  await store.run(waitForLoader(schema.loaders, createUserCustomKey));
31+
32   const expectedKey = theTestKey
33     ? `/users [POST]|${theTestKey}`
34     : createKey("/users [POST]", { email: newUEmail });
35@@ -473,9 +478,10 @@ it(tests, "createApi with custom key but no payload", async () => {
36   );
37 
38   store.run(query.bootup);
39-
40   store.dispatch(getUsers());
41-  await sleep(150);
42+
43+  await store.run(waitForLoader(schema.loaders, getUsers));
44+
45   const expectedKey = theTestKey
46     ? `/users [GET]|${theTestKey}`
47     : createKey("/users [GET]", null);
48@@ -547,7 +553,7 @@ it(tests, "errorHandler", () => {
49 });
50 
51 it(tests, "stub predicate", async () => {
52-  let actual = null;
53+  let actual: { ok: boolean } = { ok: false };
54   const api = createApi();
55   api.use(function* (ctx, next) {
56     ctx.stub = true;
57@@ -563,6 +569,7 @@ it(tests, "stub predicate", async () => {
58     function* (ctx, next) {
59       yield* next();
60       actual = ctx.json;
61+      yield* put({ type: "DONE" });
62     },
63     stub(function* (ctx, next) {
64       ctx.response = new Response(JSON.stringify({ frodo: "shire" }));
65@@ -575,7 +582,9 @@ it(tests, "stub predicate", async () => {
66   });
67   store.run(api.bootup);
68   store.dispatch(fetchUsers());
69-  await sleep(150);
70+
71+  await store.run(waitFor(() => actual.ok));
72+
73   expect(actual).toEqual({
74     ok: true,
75     value: { frodo: "shire" },
M test/put.test.ts
+3, -3
 1@@ -68,7 +68,7 @@ it(
 2   "should not cause stack overflow when puts are emitted while dispatching saga",
 3   async () => {
 4     function* root() {
 5-      for (let i = 0; i < 40_000; i += 1) {
 6+      for (let i = 0; i < 10_000; i += 1) {
 7         yield* put({ type: "test" });
 8       }
 9       yield* sleep(0);
10@@ -88,7 +88,7 @@ it(
11 
12     function* root() {
13       yield* spawn(function* firstspawn() {
14-        yield* sleep(1000);
15+        yield* sleep(10);
16         yield* put({ type: "c" });
17         yield* put({ type: "do not miss" });
18       });
19@@ -103,7 +103,7 @@ it(
20     }
21 
22     const store = configureStore({ initialState: {} });
23-    await store.run(() => root());
24+    await store.run(root);
25     const expected = ["didn't get missed"];
26     expect(actual).toEqual(expected);
27   },
M test/thunk.test.ts
+16, -6
 1@@ -1,6 +1,13 @@
 2-import { assertLike, asserts, describe, it, sleep } from "../test.ts";
 3+import { assertLike, asserts, describe, it } from "../test.ts";
 4 import { configureStore, updateStore } from "../store/mod.ts";
 5-import { call, createThunks, put, sleep as delay, takeEvery } from "../mod.ts";
 6+import {
 7+  call,
 8+  createThunks,
 9+  put,
10+  sleep as delay,
11+  takeEvery,
12+  waitFor,
13+} from "../mod.ts";
14 import type { Next, ThunkCtx } from "../mod.ts";
15 
16 // deno-lint-ignore no-explicit-any
17@@ -397,6 +404,7 @@ it(tests, "middleware order of execution", async () => {
18       acc += "a";
19       yield* next();
20       acc += "g";
21+      yield* put({ type: "DONE" });
22     },
23   );
24 
25@@ -404,7 +412,7 @@ it(tests, "middleware order of execution", async () => {
26   store.run(api.bootup);
27   store.dispatch(action());
28 
29-  await sleep(150);
30+  await store.run(waitFor(() => acc === "abcdefg"));
31   asserts.assert(acc === "abcdefg");
32 });
33 
34@@ -417,11 +425,13 @@ it(tests, "retry with actionFn", async () => {
35 
36   const action = api.create(
37     "/api",
38-    { supervisor: takeEvery },
39     function* (ctx, next) {
40       acc += "a";
41       yield* next();
42       acc += "g";
43+      if (acc === "agag") {
44+        yield* put({ type: "DONE" });
45+      }
46 
47       if (!called) {
48         called = true;
49@@ -434,7 +444,7 @@ it(tests, "retry with actionFn", async () => {
50   store.run(api.bootup);
51   store.dispatch(action());
52 
53-  await sleep(150);
54+  await store.run(waitFor(() => acc === "agag"));
55   asserts.assertEquals(acc, "agag");
56 });
57 
58@@ -464,7 +474,7 @@ it(tests, "retry with actionFn with payload", async () => {
59   store.run(api.bootup);
60   store.dispatch(action({ page: 1 }));
61 
62-  await sleep(150);
63+  await store.run(waitFor(() => acc === "agag"));
64   asserts.assertEquals(acc, "agag");
65 });
66 
M types.ts
+3, -0
 1@@ -46,6 +46,9 @@ export interface Action {
 2   type: string;
 3 }
 4 
 5+export type ActionFn = () => { toString: () => string };
 6+export type ActionFnWithPayload<P = any> = (p: P) => { toString: () => string };
 7+
 8 // https://github.com/redux-utilities/flux-standard-action
 9 export interface AnyAction extends Action {
10   payload?: any;