diff --git a/packages/amos-core/src/utils.ts b/packages/amos-core/src/utils.ts index 86d4a1b..ba104d6 100644 --- a/packages/amos-core/src/utils.ts +++ b/packages/amos-core/src/utils.ts @@ -4,10 +4,10 @@ */ import { isAmosObject, isObject, isPlainObject, isToJSON, toArray, toType } from 'amos-utils'; -import { Action } from './action'; -import { Box } from './box'; -import { Selector, SelectorFactory } from './selector'; -import { CacheOptions, type Select } from './types'; +import type { Action } from './action'; +import type { Box } from './box'; +import type { Selector, SelectorFactory } from './selector'; +import type { CacheOptions, Select } from './types'; export function stringify(data: any): string { if (Array.isArray(data)) { diff --git a/packages/amos-react/src/testUtils.ts b/packages/amos-react/src/testUtils.ts new file mode 100644 index 0000000..9fe2012 --- /dev/null +++ b/packages/amos-react/src/testUtils.ts @@ -0,0 +1,29 @@ +/* + * @since 2024-10-31 20:01:40 + * @author junbao + */ +import { renderHook, type RenderHookResult } from '@testing-library/react'; +import { createStore, Snapshot, Store } from 'amos-core'; +import { createElement } from 'react'; +import { Provider } from './context'; + +export function renderDynamicHook( + fn: (props: P) => T, + preloadedState?: Snapshot, + initialProps?: P, +): RenderHookResult & Store & { mockFn: jest.Mock } { + const store = createStore({ preloadedState }); + const mockFn = jest.fn(); + const hook = renderHook( + (props: P) => { + const value = fn(props); + mockFn(props, value); + return value; + }, + { + wrapper: (props) => createElement(Provider, { store, children: props.children }), + initialProps, + }, + ); + return Object.assign(hook, store, { mockFn: mockFn }); +} diff --git a/packages/amos-react/src/useQuery.spec.ts b/packages/amos-react/src/useQuery.spec.ts index 11ac11d..b51abf3 100644 --- a/packages/amos-react/src/useQuery.spec.ts +++ b/packages/amos-react/src/useQuery.spec.ts @@ -5,10 +5,10 @@ import { act } from '@testing-library/react'; import { action, type Dispatch, type Select } from 'amos-core'; -import { countBox, expectCalled, sleep } from 'amos-testing'; +import { countBox, expectCalled, expectCalledWith, sleep } from 'amos-testing'; import { clone } from 'amos-utils'; -import { QueryResult, useQuery } from './useQuery'; -import { renderDynamicHook } from './useSelector.spec'; +import { renderDynamicHook } from './testUtils'; +import { QueryResult, QueryResultMap, useQuery } from './useQuery'; const fn = async (dispatch: Dispatch, select: Select, multiply: number) => { dispatch(countBox.multiply(multiply)); @@ -18,10 +18,14 @@ const fn = async (dispatch: Dispatch, select: Select, multiply: number) => { }; const simpleFn = jest.fn(fn); -const simple = action(simpleFn); +const simple = action(simpleFn, { + key: 'simple', +}); const stateFn = jest.fn(fn); -const state = action(stateFn).select(countBox); +const state = action(stateFn, { + key: 'state', +}).select(countBox); describe('useQuery', () => { it('should useQuery', async () => { @@ -36,15 +40,14 @@ describe('useQuery', () => { void 0, clone(new QueryResult(), { id: a1.id, - _nextId: void 0, status: 'pending', - promise: expect.any(Promise), + q: expect.any(Promise), value: void 0, error: void 0, }), ]); await act(async () => { - await result.current[1].promise; + await result.current[1].q; await sleep(1); }); expectCalled(mockFn, 1); @@ -57,32 +60,98 @@ describe('useQuery', () => { it('should useQuery with selector', async () => { const a1 = state(1); - const { result, mockFn, rerender } = renderDynamicHook( + const { result, mockFn } = renderDynamicHook( ({ multiply }) => useQuery(state(multiply)), { count: 1 }, { multiply: 2 }, ); - expectCalled(mockFn, 1); - expect(result.current).toEqual([ - 1, - clone(new QueryResult(), { - id: a1.id, - _nextId: void 0, - status: 'pending', - promise: expect.any(Promise), - value: void 0, - error: void 0, - }), - ]); + /** + * `renderHook` will auto perform useEffect synchronously, very tricky + * the {@link state} update count synchronously and select that. + * Then the component should be rendered twice as it watches countBox. + * And the select result should be 2. + */ + expectCalled(mockFn, 2); + expectCalled(stateFn, 1); + const query = clone(new QueryResult(), { + id: a1.id, + status: 'pending', + q: expect.any(Promise), + value: void 0, + error: void 0, + }); + expect(result.current).toEqual([2, query]); await act(async () => { - await result.current[1].promise; - await sleep(1); + await result.current[1].q; }); - expectCalled(mockFn, 1); + expectCalledWith(mockFn, [ + { multiply: 2 }, + [4, clone(query, { status: 'fulfilled', value: 2 })], + ]); + }); + + it('should not dispatch for SSR', () => { + const { result, mockFn, rerender } = renderDynamicHook( + ({ m, n }) => [useQuery(simple(m)), useQuery(n % 2 ? simple(n) : state(n))], + { + 'amos.queries': { + 'simple:[2]': { + status: 'fulfilled', + value: 3, // fake value + }, + }, + }, + { m: 2, n: 2 }, + ); + expectCalled(simpleFn, 0); expectCalled(stateFn, 1); - rerender({ multiply: 1 }); expectCalled(mockFn, 1); - expect(result.current[0]).toBe(4); + expect(result.current[0][0]).toEqual(3); + expect(result.current[1][0]).toEqual(0); + rerender({ m: 2, n: 5 }); + expectCalled(simpleFn, 1); expectCalled(stateFn, 0); + expectCalled(mockFn, 1); + expect(result.current[0][0]).toEqual(3); + expect(result.current[1][0]).toEqual(void 0); + }); + + it('should serialize', () => { + const result = Object.assign(new QueryResult(), { + isFromJS: true, + q: expect.any(Promise), + status: 'fulfilled', + value: 3, // fake value + error: void 0, + }); + expect(result.toJSON()).toEqual({ + status: 'fulfilled', + value: 3, + error: void 0, + }); + + const queryMap = new QueryResultMap() + .fromJS({ + '1': { + status: 'fulfilled', + value: 3, + error: void 0, + }, + '2': { + status: 'rejected', + value: void 0, + error: {}, + }, + }) + .toJSON(); + expect(queryMap[2].q).rejects.toEqual({}); + expect(queryMap).toEqual({ + '1': result, + '2': clone(result, { + status: 'rejected', + value: void 0, + error: {}, + }), + }); }); }); diff --git a/packages/amos-react/src/useQuery.ts b/packages/amos-react/src/useQuery.ts index eca471d..151bbf1 100644 --- a/packages/amos-react/src/useQuery.ts +++ b/packages/amos-react/src/useQuery.ts @@ -13,19 +13,21 @@ import { type JSONState, type WellPartial, } from 'amos-utils'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useDispatch } from './context'; import { useSelector } from './useSelector'; -export interface QueryResultJSON extends Pick, 'status' | 'value'> {} +export interface QueryResultJSON extends Pick, 'status' | 'value' | 'error'> {} export class QueryResult implements JSONSerializable> { + /** @internal */ + isFromJS: boolean = false; /** @internal */ id: string | undefined; /** @internal */ - _nextId: string | undefined; + q: Defer = defer(); + status: 'initial' | 'pending' | 'fulfilled' | 'rejected' = 'initial'; - promise: Defer = defer(); value: Awaited | undefined = void 0; error: any = void 0; @@ -33,21 +35,19 @@ export class QueryResult implements JSONSerializable> { return { status: this.status, value: this.value, + error: this.error, }; } fromJS(state: JSONState>): this { - const p = defer(); - const r = clone(this, { - ...state, - promise: p, - } as WellPartial); - r.isRejected() ? p.reject(new Error('Server error')) : r.isFulfilled() ? p.resolve() : void 0; + const q = defer(); + const r = clone(this, { ...state, q, isFromJS: true } as WellPartial); + r.isRejected() ? q.reject(r.error) : r.isFulfilled() ? q.resolve() : void 0; return r; } isPending() { - return this.status === 'pending'; + return this.status === 'pending' || this.status === 'initial'; } isFulfilled() { @@ -81,11 +81,7 @@ export class QueryResultMap let item = this.get(key); if (item) { if (item.id === void 0) { - if (item._nextId === void 0) { - item._nextId = id; - } else if (item._nextId !== id) { - item = void 0; - } + item.id = id; } else if (item.id !== id) { item = void 0; } @@ -106,13 +102,15 @@ export const selectQuery = selector((select, key: string, id: string) => { return select(queryMapBox).getItem(key, id); }); -export const updateQuery = action((dispatch, select, key: string, query: QueryResult) => { - const map = select(queryMapBox); - if (map.get(key) === query) { - map.set(key, clone(query, {})); - } - dispatch(queryMapBox.setState(map)); -}); +export const updateQuery = action( + (dispatch, select, key: string, query: QueryResult, props: Partial>) => { + const map = select(queryMapBox); + if (map.get(key) === query) { + map.set(key, clone(query, props)); + } + dispatch(queryMapBox.setState(map)); + }, +); export interface UseQuery { ( @@ -135,26 +133,41 @@ export const useQuery: UseQuery = (action: Action | SelectableAction): [any, Que const dispatch = useDispatch(); const key = resolveCacheKey(select, action, action.conflictKey); const result = select(selectQuery(key, action.id)); - const shouldDispatch = result.status !== 'pending' && result.id !== void 0; + const lastKey = useRef(); + + const isFromJS = result.isFromJS; + const shouldDispatch = lastKey.current !== key && !isFromJS && result.status !== 'pending'; if (shouldDispatch) { + if (result.status !== 'initial') { + result.q = defer(); + } result.status = 'pending'; } + lastKey.current = key; useEffect(() => { - if (shouldDispatch) { - (async () => { - try { - result.value = await dispatch(action); - result.status = 'fulfilled'; - result.promise.resolve(); - } catch (e) { - result.error = e; - result.status = 'rejected'; - result.promise.reject(e); - } - dispatch(updateQuery(key, result)); - })(); + if (result.isFromJS) { + // only actions used in first time mounted component will not be dispatched + result.isFromJS = false; + return; + } + if (!shouldDispatch) { + return; } - }, [key, shouldDispatch]); + (async () => { + const props: Partial> = {}; + try { + props.value = await dispatch(action); + props.status = 'fulfilled'; + result.q.resolve(); + } catch (e) { + props.error = e; + props.status = 'rejected'; + result.q.reject(e); + } + dispatch(updateQuery(key, result, props)); + })(); + }, [key]); + if ('selector' in action) { return [ select( @@ -181,7 +194,7 @@ export interface UseSuspenseQuery { export const useSuspenseQuery: UseSuspenseQuery = (action: Action): any => { const [value, result] = useQuery(action); if (result.isPending()) { - throw result.promise; + throw result.q; } else if (result.isRejected()) { throw result.error; } diff --git a/packages/amos-react/src/useSelector.spec.tsx b/packages/amos-react/src/useSelector.spec.ts similarity index 86% rename from packages/amos-react/src/useSelector.spec.tsx rename to packages/amos-react/src/useSelector.spec.ts index 280068e..b888603 100644 --- a/packages/amos-react/src/useSelector.spec.tsx +++ b/packages/amos-react/src/useSelector.spec.ts @@ -3,8 +3,8 @@ * @author acrazing */ -import { act, renderHook, type RenderHookResult } from '@testing-library/react'; -import { createStore, Select, Selectable, selector, Snapshot, Store } from 'amos-core'; +import { act } from '@testing-library/react'; +import { Select, Selectable, selector, Snapshot } from 'amos-core'; import { addTwiceAsync, countBox, @@ -19,29 +19,9 @@ import { userMapBox, } from 'amos-testing'; import { arrayEqual } from 'amos-utils'; -import { Provider } from './context'; +import { renderDynamicHook } from './testUtils'; import { useSelector } from './useSelector'; -export function renderDynamicHook( - fn: (props: P) => T, - preloadedState?: Snapshot, - initialProps?: P, -): RenderHookResult & Store & { mockFn: jest.Mock } { - const store = createStore({ preloadedState }); - const mockFn = jest.fn(); - const hook = renderHook( - (props: P) => { - mockFn(props); - return fn(props); - }, - { - wrapper: (props) => {props.children}, - initialProps, - }, - ); - return Object.assign(hook, store, { mockFn: mockFn }); -} - function renderUseSelector( fn: (props: P) => Rs, preloadedState?: Snapshot, diff --git a/website/docs/01-Introduction/01-get-started.md b/website/docs/01-Introduction/01-get-started.md index ef1d406..ab2174a 100644 --- a/website/docs/01-Introduction/01-get-started.md +++ b/website/docs/01-Introduction/01-get-started.md @@ -73,5 +73,5 @@ Next, you can go to the [Concepts](./03-concepts.md) page to learn about the cor explore topics of interest in the menu on the left 👈 of this document. We recommend starting with our -article, [How to design state in large-scale applications](./04-how-to), to understand the +article, [How to design state in large-scale applications](./04-how-to.md), to understand the reasoning behind Amos's design and to help you make the most of it.