From 95bee2fe628c6148bfdd87413250a7766cf78080 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Sat, 10 Feb 2024 11:59:03 -0500 Subject: [PATCH] Wait for loader (#37) --- action.ts | 25 +++++++ fx/supervisor.ts | 2 +- query/mod.ts | 3 +- react.ts | 17 ++--- store/fx.ts | 38 +++++++++- store/query.ts | 2 +- test.ts | 7 -- test/api.test.ts | 25 ++++--- test/fetch.test.ts | 180 +++++++++++++++++++++++++++++---------------- test/mdw.test.ts | 21 ++++-- test/put.test.ts | 6 +- test/thunk.test.ts | 22 ++++-- types.ts | 3 + 13 files changed, 241 insertions(+), 110 deletions(-) diff --git a/action.ts b/action.ts index 1f9f464..0ec001d 100644 --- a/action.ts +++ b/action.ts @@ -1,5 +1,6 @@ import { call, + Callable, createContext, createSignal, each, @@ -12,6 +13,7 @@ import { import { ActionPattern, matcher } from "./matcher.ts"; import type { Action, ActionWithPayload, AnyAction } from "./types.ts"; import { createFilterQueue } from "./queue.ts"; +import { ActionFnWithPayload } from "./types.ts"; export const ActionContext = createContext( "starfx:action", @@ -103,6 +105,29 @@ export function* takeLeading( } } +export function* waitFor( + predicate: Callable, +) { + const init = yield* call(predicate as any); + if (init) { + return; + } + + while (true) { + yield* take("*"); + const result = yield* call(() => predicate as any); + if (result) { + return; + } + } +} + +export function getIdFromAction( + action: ActionWithPayload<{ key: string }> | ActionFnWithPayload, +): string { + return typeof action === "function" ? action.toString() : action.payload.key; +} + export const API_ACTION_PREFIX = ""; export const createAction = (type: string) => { if (!type) throw new Error("createAction requires non-empty string"); diff --git a/fx/supervisor.ts b/fx/supervisor.ts index 3642bda..b549b7d 100644 --- a/fx/supervisor.ts +++ b/fx/supervisor.ts @@ -11,7 +11,7 @@ export function superviseBackoff(attempt: number, max = 10): number { } /** - * {@link supvervise} will watch whatever {@link Operation} is provided + * supvervise will watch whatever {@link Operation} is provided * and it will automatically try to restart it when it exists. By * default it uses a backoff pressure mechanism so if there is an * error simply calling the {@link Operation} then it will exponentially diff --git a/query/mod.ts b/query/mod.ts index 0ab2f91..b506ad7 100644 --- a/query/mod.ts +++ b/query/mod.ts @@ -1,8 +1,9 @@ import { createThunks, type ThunksApi } from "./thunk.ts"; +import * as mdw from "./mdw.ts"; + export * from "./api.ts"; export * from "./types.ts"; export * from "./create-key.ts"; -import * as mdw from "./mdw.ts"; export { createThunks, mdw, ThunksApi }; diff --git a/react.ts b/react.ts index b8feff1..2761e44 100644 --- a/react.ts +++ b/react.ts @@ -7,6 +7,8 @@ import { import type { AnyState, LoaderState } from "./types.ts"; import type { ThunkAction } from "./query/mod.ts"; import { type FxSchema, type FxStore, PERSIST_LOADER_ID } from "./store/mod.ts"; +import { ActionFn, ActionFnWithPayload } from "./types.ts"; +import { getIdFromAction } from "./action.ts"; export { useDispatch, useSelector } from "./deps.ts"; export type { TypedUseSelectorHook } from "./deps.ts"; @@ -19,16 +21,13 @@ const { createElement: h, } = React; -type ActionFn

= (p: P) => { toString: () => string }; -type ActionFnSimple = () => { toString: () => string }; - export interface UseApiProps

extends LoaderState { trigger: (p: P) => void; - action: ActionFn

; + action: ActionFnWithPayload

; } export interface UseApiSimpleProps extends LoaderState { trigger: () => void; - action: ActionFn; + action: ActionFnWithPayload; } export interface UseApiAction extends LoaderState { @@ -93,10 +92,10 @@ export function useSchema() { * ``` */ export function useLoader( - action: ThunkAction | ActionFn, + action: ThunkAction | ActionFnWithPayload, ) { const schema = useSchema(); - const id = typeof action === "function" ? `${action}` : action.payload.key; + const id = getIdFromAction(action); return useSelector((s: S) => schema.loaders.selectById(s, { id })); } @@ -131,10 +130,10 @@ export function useApi

>( action: A, ): UseApiAction; export function useApi

>( - action: ActionFn

, + action: ActionFnWithPayload

, ): UseApiProps

; export function useApi( - action: ActionFnSimple, + action: ActionFn, ): UseApiSimpleProps; export function useApi(action: any): any { const dispatch = useDispatch(); diff --git a/store/fx.ts b/store/fx.ts index 5cfd5da..415a9a8 100644 --- a/store/fx.ts +++ b/store/fx.ts @@ -1,9 +1,11 @@ import { Operation, Result } from "../deps.ts"; -import type { AnyState } from "../types.ts"; +import type { ActionFnWithPayload, AnyState } from "../types.ts"; import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.ts"; import { StoreContext } from "./context.ts"; import { LoaderOutput } from "./slice/loader.ts"; -import { safe } from "../fx/mod.ts"; +import { parallel, safe } from "../fx/mod.ts"; +import { ThunkAction } from "../query/mod.ts"; +import { getIdFromAction, take } from "../action.ts"; export function* updateStore( updater: StoreUpdater | StoreUpdater[], @@ -28,6 +30,38 @@ export function* select( return selectorFn(store.getState() as S, p); } +export function* waitForLoader( + loaders: LoaderOutput, + action: ThunkAction | ActionFnWithPayload, +) { + const id = getIdFromAction(action); + const selector = (s: AnyState) => loaders.selectById(s, { id }); + + // check for done state on init + let loader = yield* select(selector); + if (loader.isSuccess || loader.isError) { + return loader; + } + + while (true) { + yield* take("*"); + loader = yield* select(selector); + if (loader.isSuccess || loader.isError) { + return loader; + } + } +} + +export function* waitForLoaders( + loaders: LoaderOutput, + actions: (ThunkAction | ActionFnWithPayload)[], +) { + const group = yield* parallel( + actions.map((action) => waitForLoader(loaders, action)), + ); + return yield* group; +} + export function createTracker>( loader: LoaderOutput, ) { diff --git a/store/query.ts b/store/query.ts index 6afe165..c103d9a 100644 --- a/store/query.ts +++ b/store/query.ts @@ -128,7 +128,7 @@ export function loaderApi< } if (!ctx.loader) { - ctx.loader || {}; + ctx.loader = {}; } if (!ctx.response.ok) { diff --git a/test.ts b/test.ts index 07ceae2..88fa907 100644 --- a/test.ts +++ b/test.ts @@ -8,13 +8,6 @@ export * as asserts from "https://deno.land/std@0.185.0/testing/asserts.ts"; export { expect } from "https://deno.land/x/expect@v0.3.0/mod.ts"; export { install, mock } from "https://deno.land/x/mock_fetch@0.3.0/mod.ts"; -export const sleep = (n: number) => - new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, n); - }); - export function isLikeSelector(selector: unknown) { return ( selector !== null && diff --git a/test/api.test.ts b/test/api.test.ts index 2c9ce65..8f0c7c9 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,21 +1,25 @@ import { describe, expect, it } from "../test.ts"; -import { sleep } from "../test.ts"; import { configureStore, createSchema, + select, slice, storeMdw, updateStore, + waitForLoader, } from "../store/mod.ts"; import { + AnyState, type ApiCtx, call, createApi, createKey, keepAlive, mdw, + Operation, safe, takeEvery, + waitFor, } from "../mod.ts"; import { useCache } from "../react.ts"; @@ -44,9 +48,8 @@ const jsonBlob = (data: unknown) => { const tests = describe("createApi()"); -it(tests, "createApi - POST", async () => { +it(tests, "POST", async () => { const query = createApi(); - query.use(mdw.queryCtx); query.use(mdw.nameParser); query.use(query.routes()); @@ -102,7 +105,12 @@ it(tests, "createApi - POST", async () => { store.run(query.bootup); store.dispatch(createUser({ email: mockUser.email })); - await sleep(150); + + await store.run(waitFor(function* (): Operation { + const res = yield* select((state: AnyState) => state.users["1"].id); + return res !== ""; + })); + expect(store.getState().users).toEqual({ "1": { id: "1", name: "test", email: "test@test.com" }, }); @@ -292,7 +300,7 @@ it(tests, "with hash key on a large post", async () => { const action = createUserDefaultKey({ email, largetext }); store.dispatch(action); - await sleep(150); + await store.run(waitForLoader(schema.loaders, action)); const s = store.getState(); const expectedKey = createKey(action.payload.name, { @@ -306,18 +314,16 @@ it(tests, "with hash key on a large post", async () => { }); }); -it(tests, "createApi - two identical endpoints", async () => { +it(tests, "two identical endpoints", () => { const actual: string[] = []; const { store, schema } = testStore(); const api = createApi(); api.use(mdw.api()); api.use(storeMdw.store(schema)); - api.use(mdw.nameParser); api.use(api.routes()); const first = api.get( "/health", - { supervisor: takeEvery }, function* (ctx, next) { actual.push(ctx.req().url); yield* next(); @@ -326,7 +332,6 @@ it(tests, "createApi - two identical endpoints", async () => { const second = api.get( ["/health", "poll"], - { supervisor: takeEvery }, function* (ctx, next) { actual.push(ctx.req().url); yield* next(); @@ -337,8 +342,6 @@ it(tests, "createApi - two identical endpoints", async () => { store.dispatch(first()); store.dispatch(second()); - await sleep(150); - expect(actual).toEqual(["/health", "/health"]); }); diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 8e11b6c..19a4014 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -1,17 +1,19 @@ import { describe, expect, install, it, mock } from "../test.ts"; -import { configureStore, createSchema, slice, storeMdw } from "../store/mod.ts"; -import { createApi, mdw, takeEvery } from "../mod.ts"; +import { + configureStore, + createSchema, + slice, + storeMdw, + waitForLoader, + waitForLoaders, +} from "../store/mod.ts"; +import { ApiCtx, createApi, mdw, takeEvery } from "../mod.ts"; install(); const baseUrl = "https://starfx.com"; const mockUser = { id: "1", email: "test@starfx.com" }; -const delay = (n = 200) => - new Promise((resolve) => { - setTimeout(resolve, n); - }); - const testStore = () => { const [schema, initialState] = createSchema({ loaders: slice.loader(), @@ -21,6 +23,10 @@ const testStore = () => { return { schema, store }; }; +const getTestData = (ctx: ApiCtx) => { + return { request: { ...ctx.req() }, json: { ...ctx.json } }; +}; + const tests = describe("mdw.fetch()"); it( @@ -57,11 +63,10 @@ it( const action = fetchUsers(); store.dispatch(action); - await delay(); + await store.run(waitForLoader(schema.loaders, action)); const state = store.getState(); expect(state.cache[action.payload.key]).toEqual(mockUser); - expect(actual).toEqual([{ url: `${baseUrl}/users`, method: "GET", @@ -104,7 +109,8 @@ it( const action = fetchUsers(); store.dispatch(action); - await delay(); + await store.run(waitForLoader(schema.loaders, action)); + const data = "this is some text"; expect(actual).toEqual({ ok: true, data, value: data }); }, @@ -140,7 +146,7 @@ it(tests, "error handling", async () => { const action = fetchUsers(); store.dispatch(action); - await delay(); + await store.run(waitForLoader(schema.loaders, action)); const state = store.getState(); expect(state.cache[action.payload.key]).toEqual(errMsg); @@ -180,7 +186,7 @@ it(tests, "status 204", async () => { const action = fetchUsers(); store.dispatch(action); - await delay(); + await store.run(waitForLoader(schema.loaders, action)); const state = store.getState(); expect(state.cache[action.payload.key]).toEqual({}); @@ -220,7 +226,7 @@ it(tests, "malformed json", async () => { const action = fetchUsers(); store.dispatch(action); - await delay(); + await store.run(waitForLoader(schema.loaders, action)); const data = { message: "Unexpected token 'o', \"not json\" is not valid JSON", @@ -255,16 +261,7 @@ it(tests, "POST", async () => { }); yield* next(); - expect(ctx.req()).toEqual({ - url: `${baseUrl}/users`, - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: JSON.stringify(mockUser), - }); - - expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser }); + ctx.loader = { meta: getTestData(ctx) }; }, ); @@ -272,7 +269,25 @@ it(tests, "POST", async () => { const action = fetchUsers(); store.dispatch(action); - await delay(); + const loader = await store.run(waitForLoader(schema.loaders, action)); + if (!loader.ok) { + throw loader.error; + } + + expect(loader.value.meta.request).toEqual({ + url: `${baseUrl}/users`, + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify(mockUser), + }); + + expect(loader.value.meta.json).toEqual({ + ok: true, + data: mockUser, + value: mockUser, + }); }); it(tests, "POST multiple endpoints with same uri", async () => { @@ -296,16 +311,7 @@ it(tests, "POST multiple endpoints with same uri", async () => { ctx.request = ctx.req({ body: JSON.stringify(mockUser) }); yield* next(); - expect(ctx.req()).toEqual({ - url: `${baseUrl}/users/1/something`, - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: JSON.stringify(mockUser), - }); - - expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser }); + ctx.loader = { meta: getTestData(ctx) }; }, ); @@ -316,38 +322,74 @@ it(tests, "POST multiple endpoints with same uri", async () => { ctx.cache = true; ctx.request = ctx.req({ body: JSON.stringify(mockUser) }); yield* next(); - - expect(ctx.req()).toEqual({ - url: `${baseUrl}/users/1/something`, - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: JSON.stringify(mockUser), - }); - - expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser }); + ctx.loader = { meta: getTestData(ctx) }; }, ); store.run(api.bootup); - store.dispatch(fetchUsers({ id: "1" })); - store.dispatch(fetchUsersSecond({ id: "1" })); + const action1 = fetchUsers({ id: "1" }); + const action2 = fetchUsersSecond({ id: "1" }); + store.dispatch(action1); + store.dispatch(action2); + + const results = await store.run( + waitForLoaders(schema.loaders, [action1, action2]), + ); + if (!results.ok) { + throw results.error; + } + const result1 = results.value[0]; + if (!result1.ok) { + throw result1.error; + } + const result2 = results.value[1]; + if (!result2.ok) { + throw result2.error; + } + + expect(result1.value.meta.request).toEqual({ + url: `${baseUrl}/users/1/something`, + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify(mockUser), + }); + + expect(result1.value.meta.json).toEqual({ + ok: true, + data: mockUser, + value: mockUser, + }); - await delay(); + expect(result2.value.meta.request).toEqual({ + url: `${baseUrl}/users/1/something`, + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify(mockUser), + }); + + expect(result2.value.meta.json).toEqual({ + ok: true, + data: mockUser, + value: mockUser, + }); }); it( tests, "slug in url but payload has empty string for slug value", - async () => { + () => { const { store, schema } = testStore(); const api = createApi(); api.use(mdw.api()); api.use(storeMdw.store(schema)); api.use(api.routes()); api.use(mdw.fetch({ baseUrl })); + let actual = ""; const fetchUsers = api.post<{ id: string }>( "/users/:id", @@ -357,14 +399,9 @@ it( ctx.request = ctx.req({ body: JSON.stringify(mockUser) }); yield* next(); - - const data = - "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()"; - expect(ctx.json).toEqual({ - ok: false, - data, - error: data, - }); + if (!ctx.json.ok) { + actual = ctx.json.error; + } }, ); @@ -372,7 +409,9 @@ it( const action = fetchUsers({ id: "" }); store.dispatch(action); - await delay(); + const data = + "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()"; + expect(actual).toEqual(data); }, ); @@ -418,7 +457,10 @@ it( const action = fetchUsers(); store.dispatch(action); - await delay(); + const loader = await store.run(waitForLoader(schema.loaders, action)); + if (!loader.ok) { + throw loader.error; + } const state = store.getState(); expect(state.cache[action.payload.key]).toEqual(mockUser); @@ -457,7 +499,10 @@ it( const action = fetchUsers(); store.dispatch(action); - await delay(); + const loader = await store.run(waitForLoader(schema.loaders, action)); + if (!loader.ok) { + throw loader.error; + } const data = { message: "error" }; expect(actual).toEqual({ ok: false, data, error: data }); }, @@ -486,7 +531,10 @@ it( store.run(api.bootup); store.dispatch(fetchUsers()); - await delay(); + const loader = await store.run(waitForLoader(schema.loaders, fetchUsers)); + if (!loader.ok) { + throw loader.error; + } expect(actual).toEqual({ ok: true, data: mockUser, value: mockUser }); }, ); @@ -514,12 +562,18 @@ it(tests, "should use dynamic mdw to mock response", async () => { const dynamicUser = { id: "2", email: "dynamic@starfx.com" }; fetchUsers.use(mdw.response(new Response(JSON.stringify(dynamicUser)))); store.dispatch(fetchUsers()); - await delay(); + let loader = await store.run(waitForLoader(schema.loaders, fetchUsers)); + if (!loader.ok) { + throw loader.error; + } expect(actual).toEqual({ ok: true, data: dynamicUser, value: dynamicUser }); // reset dynamic mdw and try again api.reset(); store.dispatch(fetchUsers()); - await delay(); + loader = await store.run(waitForLoader(schema.loaders, fetchUsers)); + if (!loader.ok) { + throw loader.error; + } expect(actual).toEqual({ ok: true, data: mockUser, value: mockUser }); }); diff --git a/test/mdw.test.ts b/test/mdw.test.ts index ac584c5..40e8e99 100644 --- a/test/mdw.test.ts +++ b/test/mdw.test.ts @@ -1,18 +1,21 @@ -import { assertLike, asserts, describe, expect, it, sleep } from "../test.ts"; +import { assertLike, asserts, describe, expect, it } from "../test.ts"; import { configureStore, createSchema, slice, storeMdw, updateStore, + waitForLoader, } from "../store/mod.ts"; import { createApi, createKey, mdw, + put, safe, takeEvery, takeLatest, + waitFor, } from "../mod.ts"; import type { ApiCtx, Next, ThunkCtx } from "../mod.ts"; @@ -405,7 +408,9 @@ it(tests, "createApi with own key", async () => { store.run(query.bootup); store.dispatch(createUserCustomKey({ email: newUEmail })); - await sleep(150); + + await store.run(waitForLoader(schema.loaders, createUserCustomKey)); + const expectedKey = theTestKey ? `/users [POST]|${theTestKey}` : createKey("/users [POST]", { email: newUEmail }); @@ -473,9 +478,10 @@ it(tests, "createApi with custom key but no payload", async () => { ); store.run(query.bootup); - store.dispatch(getUsers()); - await sleep(150); + + await store.run(waitForLoader(schema.loaders, getUsers)); + const expectedKey = theTestKey ? `/users [GET]|${theTestKey}` : createKey("/users [GET]", null); @@ -547,7 +553,7 @@ it(tests, "errorHandler", () => { }); it(tests, "stub predicate", async () => { - let actual = null; + let actual: { ok: boolean } = { ok: false }; const api = createApi(); api.use(function* (ctx, next) { ctx.stub = true; @@ -563,6 +569,7 @@ it(tests, "stub predicate", async () => { function* (ctx, next) { yield* next(); actual = ctx.json; + yield* put({ type: "DONE" }); }, stub(function* (ctx, next) { ctx.response = new Response(JSON.stringify({ frodo: "shire" })); @@ -575,7 +582,9 @@ it(tests, "stub predicate", async () => { }); store.run(api.bootup); store.dispatch(fetchUsers()); - await sleep(150); + + await store.run(waitFor(() => actual.ok)); + expect(actual).toEqual({ ok: true, value: { frodo: "shire" }, diff --git a/test/put.test.ts b/test/put.test.ts index bceb3db..f730c09 100644 --- a/test/put.test.ts +++ b/test/put.test.ts @@ -68,7 +68,7 @@ it( "should not cause stack overflow when puts are emitted while dispatching saga", async () => { function* root() { - for (let i = 0; i < 40_000; i += 1) { + for (let i = 0; i < 10_000; i += 1) { yield* put({ type: "test" }); } yield* sleep(0); @@ -88,7 +88,7 @@ it( function* root() { yield* spawn(function* firstspawn() { - yield* sleep(1000); + yield* sleep(10); yield* put({ type: "c" }); yield* put({ type: "do not miss" }); }); @@ -103,7 +103,7 @@ it( } const store = configureStore({ initialState: {} }); - await store.run(() => root()); + await store.run(root); const expected = ["didn't get missed"]; expect(actual).toEqual(expected); }, diff --git a/test/thunk.test.ts b/test/thunk.test.ts index 93e95ea..d5eb106 100644 --- a/test/thunk.test.ts +++ b/test/thunk.test.ts @@ -1,6 +1,13 @@ -import { assertLike, asserts, describe, it, sleep } from "../test.ts"; +import { assertLike, asserts, describe, it } from "../test.ts"; import { configureStore, updateStore } from "../store/mod.ts"; -import { call, createThunks, put, sleep as delay, takeEvery } from "../mod.ts"; +import { + call, + createThunks, + put, + sleep as delay, + takeEvery, + waitFor, +} from "../mod.ts"; import type { Next, ThunkCtx } from "../mod.ts"; // deno-lint-ignore no-explicit-any @@ -397,6 +404,7 @@ it(tests, "middleware order of execution", async () => { acc += "a"; yield* next(); acc += "g"; + yield* put({ type: "DONE" }); }, ); @@ -404,7 +412,7 @@ it(tests, "middleware order of execution", async () => { store.run(api.bootup); store.dispatch(action()); - await sleep(150); + await store.run(waitFor(() => acc === "abcdefg")); asserts.assert(acc === "abcdefg"); }); @@ -417,11 +425,13 @@ it(tests, "retry with actionFn", async () => { const action = api.create( "/api", - { supervisor: takeEvery }, function* (ctx, next) { acc += "a"; yield* next(); acc += "g"; + if (acc === "agag") { + yield* put({ type: "DONE" }); + } if (!called) { called = true; @@ -434,7 +444,7 @@ it(tests, "retry with actionFn", async () => { store.run(api.bootup); store.dispatch(action()); - await sleep(150); + await store.run(waitFor(() => acc === "agag")); asserts.assertEquals(acc, "agag"); }); @@ -464,7 +474,7 @@ it(tests, "retry with actionFn with payload", async () => { store.run(api.bootup); store.dispatch(action({ page: 1 })); - await sleep(150); + await store.run(waitFor(() => acc === "agag")); asserts.assertEquals(acc, "agag"); }); diff --git a/types.ts b/types.ts index 48d0bae..d341735 100644 --- a/types.ts +++ b/types.ts @@ -46,6 +46,9 @@ export interface Action { type: string; } +export type ActionFn = () => { toString: () => string }; +export type ActionFnWithPayload

= (p: P) => { toString: () => string }; + // https://github.com/redux-utilities/flux-standard-action export interface AnyAction extends Action { payload?: any;