Skip to content

Commit

Permalink
feat: use query
Browse files Browse the repository at this point in the history
  • Loading branch information
acrazing committed Nov 1, 2024
1 parent 053d739 commit 0e62683
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 93 deletions.
8 changes: 4 additions & 4 deletions packages/amos-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
29 changes: 29 additions & 0 deletions packages/amos-react/src/testUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* @since 2024-10-31 20:01:40
* @author junbao <[email protected]>
*/
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<P, T>(
fn: (props: P) => T,
preloadedState?: Snapshot,
initialProps?: P,
): RenderHookResult<T, P> & 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 });
}
121 changes: 95 additions & 26 deletions packages/amos-react/src/useQuery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 () => {
Expand All @@ -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);
Expand All @@ -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: {},
}),
});
});
});
91 changes: 52 additions & 39 deletions packages/amos-react/src/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,41 +13,41 @@ 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<R> extends Pick<QueryResult<R>, 'status' | 'value'> {}
export interface QueryResultJSON<R> extends Pick<QueryResult<R>, 'status' | 'value' | 'error'> {}

export class QueryResult<R> implements JSONSerializable<QueryResultJSON<R>> {
/** @internal */
isFromJS: boolean = false;
/** @internal */
id: string | undefined;
/** @internal */
_nextId: string | undefined;
q: Defer<void> = defer();

status: 'initial' | 'pending' | 'fulfilled' | 'rejected' = 'initial';
promise: Defer<void> = defer();
value: Awaited<R> | undefined = void 0;
error: any = void 0;

toJSON(): QueryResultJSON<R> {
return {
status: this.status,
value: this.value,
error: this.error,
};
}

fromJS(state: JSONState<QueryResultJSON<R>>): this {
const p = defer<void>();
const r = clone(this, {
...state,
promise: p,
} as WellPartial<this>);
r.isRejected() ? p.reject(new Error('Server error')) : r.isFulfilled() ? p.resolve() : void 0;
const q = defer<void>();
const r = clone(this, { ...state, q, isFromJS: true } as WellPartial<this>);
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() {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<any>) => {
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<any>, props: Partial<QueryResult<any>>) => {
const map = select(queryMapBox);
if (map.get(key) === query) {
map.set(key, clone(query, props));
}
dispatch(queryMapBox.setState(map));
},
);

export interface UseQuery {
<A extends any[] = any, R = any, S = any>(
Expand All @@ -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<string>();

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<QueryResult<any>> = {};
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(
Expand All @@ -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;
}
Expand Down
Loading

0 comments on commit 0e62683

Please sign in to comment.