Skip to content

Commit

Permalink
feat: support getComputed in module and methods
Browse files Browse the repository at this point in the history
  • Loading branch information
InfiniteXyy committed Jul 1, 2023
1 parent ecc59b8 commit 9de9eeb
Show file tree
Hide file tree
Showing 8 changed files with 119 additions and 27 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.5.6

- feat: Add `getComputed` to module, support call `getComputed` in a method builder

## 0.5.5

- fix: Add `index.d.ts` file, update module resolve setting for `ts5` bundler resolver, Thanks contribution from [black7375](https://github.com/InfiniteXyy/zoov/pull/11#issuecomment-1614037786)
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zoov",
"version": "0.5.5",
"version": "0.5.6",
"author": "InfiniteXyy",
"license": "MIT",
"main": "index.js",
Expand Down
28 changes: 19 additions & 9 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ export function extendComputed(computed: ComputedRecord, rawModule: RawModule):
}

// Support two ways of methods definition: this type & function type
export function extendMethods<State extends StateRecord, Actions extends ActionsRecord<State>>(
builder: MethodBuilderFn<State, Actions> | MethodBuilder,
export function extendMethods<State extends StateRecord, Actions extends ActionsRecord<State>, Computed extends ComputedRecord>(
builder: MethodBuilderFn<State, Actions, Computed> | MethodBuilder,
rawModule: RawModule
): RawModule {
const builderFn =
typeof builder === 'function'
? builder
: (perform: Perform<State, Actions>) =>
: (perform: Perform<State, Actions, Computed>) =>
Object.keys(builder).reduce((acc, cur) => {
acc[cur] = builder[cur].bind(perform);
return acc;
Expand Down Expand Up @@ -72,12 +72,17 @@ export function buildModule<State extends StateRecord, Actions extends ActionsRe
const stateCreator: StateCreator<any, any, any> = redux(scopeReducer, mergedState);
const middlewares = middleware ? [middleware] : rawModule.middlewares;

const computed: ComputedRecord = {};
// the hooks map has getter functions, which use the getter function as selector to the store
const computedHooksMap: ComputedRecord = {};
// The raw map is only a getter function, without subscription to the zustand store
const computedRawMap: ComputedRecord = {};

const cachedActionsMap = new WeakMap<ScopeContext, ActionsRecord<State>>();

const self: Scope<State, Actions> = {
store: create(middlewares.reduce((acc, middleware) => middleware(acc), stateCreator)),
getComputed: () => computed,
getComputed: () => computedRawMap,
getComputedHooks: () => computedHooksMap,
getActions: (context: ScopeContext) => {
const cachedAction = cachedActionsMap.get(context);
if (cachedAction) return cachedAction as Actions & ActionsRecord<State>;
Expand Down Expand Up @@ -115,17 +120,18 @@ export function buildModule<State extends StateRecord, Actions extends ActionsRe
Object.keys(rawModule.reducers).forEach((key: any) => {
(actions as Record<string, (...args: any) => unknown>)[key] = (...args: any) => dispatch({ type: key, payload: args });
});
const getScope = (module?: HooksModule<any, any>): Scope<any, any> => {
const getScope = (module?: HooksModule<any, any, any>): Scope<any, any, any> => {
if (!module) return self;
return getScopeOrBuild(context, module);
};
let isBuildingMethods = false;
const perform: Perform<State, Actions & ActionsRecord<State>> = {
const perform: Perform<State, Actions & ActionsRecord<State>, ComputedRecord> = {
getState: (module?: HooksModule) => getScope(module).getState(),
getActions: (module?: HooksModule) => {
if (isBuildingMethods) throw new Error('should not call getActions in the method builder, call it inside a method.');
return getScope(module).getActions(context);
},
getComputed: (module?: HooksModule) => getScope(module).getComputed(),
};
isBuildingMethods = true;
rawModule.methodsBuilders.forEach((builder) => {
Expand All @@ -141,9 +147,12 @@ export function buildModule<State extends StateRecord, Actions extends ActionsRe
// bind Computed
Object.keys(rawModule.computed).forEach((key) => {
rawModule.computed[key] = simpleMemoizedFn(rawModule.computed[key]);
Object.defineProperty(computed, key, {
Object.defineProperty(computedHooksMap, key, {
get: () => self.store(rawModule.computed[key]),
});
Object.defineProperty(computedRawMap, key, {
get: () => rawModule.computed[key](self.getState()),
});
});

// bind Subscription
Expand All @@ -164,11 +173,12 @@ export function buildModule<State extends StateRecord, Actions extends ActionsRe
use: (selector: (state: State) => unknown, equalFn: EqualityChecker<unknown>) => [module.useState(selector, equalFn), module.useActions(), module.useComputed()],
useState: (selector: (state: State) => unknown, equalFn: EqualityChecker<unknown>) => useScope().store(selector, equalFn),
useActions: () => useScope().getActions(useScopeContext()),
useComputed: () => useScope().getComputed(),
useComputed: () => useScope().getComputedHooks(),
useStore: () => useScope().store,
getStore: (context = globalContext) => getScopeOrBuild(context, module).store,
getState: (context = globalContext) => getScopeOrBuild(context, module).getState(),
getActions: (context = globalContext) => getScopeOrBuild(context, module).getActions(context),
getComputed: (context = globalContext) => getScopeOrBuild(context, module).getComputed(),
[__buildScopeSymbol]: buildScope,
} as HooksModule<State, Actions>;

Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ function factory<State extends StateRecord>(state: State, rawModule: RawModule<a
return {
actions: (actions) => factory(state, extendActions(actions, rawModule)),
computed: (computed) => factory(state, extendComputed(computed, rawModule)),
methods: (methods: MethodBuilderFn<State, any> | MethodBuilder) => factory(state, extendMethods(methods, rawModule)),
methods: (methods: MethodBuilderFn<State, any, any> | MethodBuilder) => factory(state, extendMethods(methods, rawModule)),
middleware: (middleware) => factory(state, extendMiddleware(middleware, rawModule)),
subscribe: (subscriber) => factory(state, extendSubscribe(subscriber, rawModule)),
build: buildModule(state, rawModule),
Expand Down Expand Up @@ -47,4 +47,4 @@ export type InferModule<M> = M extends { getState(): infer S; getActions(): infe

export { defineModule, defineProvider, useScopeContext, useModule, useModuleActions, useModuleComputed };

export const VERSION = '0.5.5';
export const VERSION = '0.5.6';
27 changes: 21 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@ export type Action = (...args: any) => void;
export type ActionsRecord<State> = { $setState: SetState<State>; $reset: () => void };
export type ComputedRecord = Record<string, any>;

export type Perform<State extends StateRecord, Actions extends ActionsRecord<State>> = {
export type Perform<State extends StateRecord, Actions extends ActionsRecord<State>, Computed extends ComputedRecord> = {
getActions<M extends HooksModule<any> = HooksModule<State, Actions>>(module?: M): M extends HooksModule<any, infer A> ? A : never;
getState<M extends HooksModule<any> = HooksModule<State, Actions>>(module?: M): M extends HooksModule<infer S> ? S : never;
getComputed<M extends HooksModule<any> = HooksModule<State, Actions, Computed>>(module?: M): M extends HooksModule<any, any, infer C> ? C : never;
};

/* Core Types */
export type ActionBuilder<State extends StateRecord> = Record<string, (draft: Draft<State>, ...args: any) => void>;
export type ComputedBuilder<State extends StateRecord> = Record<string, (state: State) => any>;
export type MethodBuilderFn<State extends StateRecord, Actions extends ActionsRecord<State>> = (perform: Perform<State, Actions>) => Record<any, (...args: any) => any>;
export type MethodBuilderFn<State extends StateRecord, Actions extends ActionsRecord<State>, Computed extends ComputedRecord> = (
perform: Perform<State, Actions, Computed>
) => Record<any, (...args: any) => any>;
export type MethodBuilder = Record<any, (...args: any) => any>;
export type MiddlewareBuilder<State extends StateRecord> = (creator: StateCreator<State, any, any, any>) => StateCreator<State, any, any, any>;
export type SubscribeListener<T> = (state: T, prevState: T, options: { addCleanup: (cleanup: () => void) => void }) => void | Promise<void>;
Expand All @@ -38,11 +41,11 @@ export type SubscribeBuilder<State, T extends unknown> =
}
| SubscribeListener<T>;

export type RawModule<State extends StateRecord = {}, Actions extends ActionsRecord<State> = ActionsRecord<State>> = {
export type RawModule<State extends StateRecord = {}, Actions extends ActionsRecord<State> = ActionsRecord<State>, Computed extends ComputedRecord = {}> = {
computed: Record<string, (state: State) => any>;
// "reducers" and "methodsBuilders" will be turned into actions
reducers: Record<string, Reducer<State>>;
methodsBuilders: MethodBuilderFn<State, Actions>[];
methodsBuilders: MethodBuilderFn<State, Actions, Computed>[];
middlewares: MiddlewareBuilder<State>[];
subscriptionBuilders: ((initialState: State) => (state: State) => void)[];
};
Expand All @@ -55,8 +58,8 @@ export type ModuleFactory<
> = {
actions<A extends ActionBuilder<State>>(actions: A): Omit<ModuleFactory<State, GenAction<A> & Actions, Computed, Excluded | 'actions'>, Excluded | 'actions'>;
computed<C extends ComputedBuilder<State>>(computed: C): Omit<ModuleFactory<State, Actions, GenComputed<C>, Excluded | 'computed'>, Excluded | 'computed'>;
methods<ME extends Record<any, (...args: any) => any>>(methods: ThisType<Perform<State, Actions>> & ME): ModuleFactory<State, ME & Actions, Computed, Excluded>;
methods<MB extends MethodBuilderFn<State, Actions>>(builder: MB): ModuleFactory<State, ReturnType<MB> & Actions, Computed, Excluded>;
methods<ME extends Record<any, (...args: any) => any>>(methods: ThisType<Perform<State, Actions, Computed>> & ME): ModuleFactory<State, ME & Actions, Computed, Excluded>;
methods<MB extends MethodBuilderFn<State, Actions, Computed>>(builder: MB): ModuleFactory<State, ReturnType<MB> & Actions, Computed, Excluded>;
middleware<M extends MiddlewareBuilder<State>>(middleware: M): Omit<ModuleFactory<State, Actions, Computed, Excluded | 'middleware'>, Excluded | 'middleware'>;
subscribe<T = State>(subscriber: SubscribeBuilder<State, T>): ModuleFactory<State, Actions, Computed, Excluded>;
build(): HooksModule<State, Actions, Computed>;
Expand All @@ -67,6 +70,9 @@ export const __buildScopeSymbol = Symbol('buildScope');
export type Scope<State extends StateRecord = {}, Actions extends ActionsRecord<State> = ActionsRecord<State>, Computed extends ComputedRecord = {}> = {
store: UseBoundStore<StoreApi<State>>;
getActions(context: ScopeContext): Actions;
/** Computed with subscription to zustand */
getComputedHooks(): Computed;
/** Computed without subscription */
getComputed(): Computed;
getState(): State;
};
Expand All @@ -85,17 +91,26 @@ export type HooksModule<State extends StateRecord = {}, Actions extends ActionsR
/** get zustand store, can parse scope with `useScopeContext()` */
getStore(scope?: ScopeContext): UseBoundStore<StoreApi<State>>;
/** Retrieve state outside React components,
*
* note: By default, the return value will be the global module state.
* If you want to get "scope-inner" state, you must use the scope parameter.
* you can get the scope via hooks `useScopeContext()`
*/
getState(scope?: ScopeContext): State;
/** Retrieve actions outside React components,
*
* note: By default, the return value will be the global module state.
* If you want to get "scope-inner" actions, you must use the scope parameter.
* you can get the scope via hooks `useScopeContext()`
*/
getActions(scope?: ScopeContext): Actions;
/** Retrieve computed outside React components,
*
* note: By default, the return value will be the global module state.
* If you want to get "scope-inner" computed, you must use the scope parameter.
* you can get the scope via hooks `useScopeContext()`
*/
getComputed(scope?: ScopeContext): Computed;
};

/* Auto setState, copied from solid-js/store/types */
Expand Down
52 changes: 48 additions & 4 deletions test/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,14 @@ describe('test hooks', function () {
});

it('should computed only be triggered once when state not changed', function () {
const spy = vi.fn();
const mockHeavyLogic = vi.fn();
const module = emptyModule
.actions({
add: (draft, value: number) => (draft.count += value),
})
.computed({
doubled: (state) => {
spy();
mockHeavyLogic();
return state.count * 2;
},
})
Expand All @@ -106,9 +106,11 @@ describe('test hooks', function () {
const { doubled } = module.useComputed();
return { doubled };
});
expect(spy).toBeCalledTimes(1);
expect(result.current.doubled).toBe(0);
expect(mockHeavyLogic).toBeCalledTimes(1);
act(() => void result.current.add(2));
expect(spy).toBeCalledTimes(2);
expect(result.current.doubled).toBe(4);
expect(mockHeavyLogic).toBeCalledTimes(2);
});

it("should computed only be triggered when it's dependency changes", () => {
Expand All @@ -131,6 +133,48 @@ describe('test hooks', function () {
expect(spy).toHaveBeenCalledTimes(1); // only the first time
});

it('should computed be available in the method function', () => {
const mockHeavyLogic = vi.fn();
const mockAddCallback = vi.fn();
const module = emptyModule
.computed({
doubled: (state) => {
mockHeavyLogic();
return state.count * 2;
},
})
.methods(({ getComputed, getActions }) => ({
add1: () => {
// test with arrow function
getActions().$setState('count', (i) => i + 1);
mockAddCallback(getComputed().doubled);
},
}))
.methods({
add2() {
// test with this
this.getActions().$setState('count', (i) => i + 1);
mockAddCallback(this.getComputed().doubled);
},
})
.build();
const { result } = renderHook(() => {
const [, { add1, add2 }, { doubled }] = module.use();
return { doubled, add1, add2 };
});
expect(mockHeavyLogic).toBeCalledTimes(1); // first time render

act(() => result.current.add1());
expect(mockHeavyLogic).toBeCalledTimes(2);
expect(mockAddCallback).toHaveBeenLastCalledWith(2);

act(() => result.current.add2());
expect(mockHeavyLogic).toHaveBeenCalledTimes(3);
expect(mockAddCallback).toHaveBeenLastCalledWith(4);

expect(module.getComputed().doubled).toBe(4);
});

it.each([
{
name: 'Functional Style',
Expand Down
2 changes: 2 additions & 0 deletions test/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const module = defineModule<ModuleState>({ count: 0 })
async addAsync(count?: number) {
expectType<ModuleActions>(self.getActions());
expectType<ModuleState>(self.getState());
expectType<ModuleComputed>(self.getComputed());
await Promise.resolve();
self.getActions().add(count);
},
Expand All @@ -37,6 +38,7 @@ const module = defineModule<ModuleState>({ count: 0 })
methodWithThis(_payload: string) {
expectType<ModuleActions & Omit<ModuleMethods, 'methodWithThis'>>(this.getActions());
expectType<ModuleState>(this.getState());
expectType<ModuleComputed>(this.getComputed());
},
})
.middleware((store) => {
Expand Down
27 changes: 22 additions & 5 deletions test/scope-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ describe('test scope context', () => {
.build();

const module = defineModule<State>({ count: 0 })
.computed({
doubled: (state) => state.count * 2,
})
.actions({
setCount: (draft, value) => (draft.count = value),
})
Expand Down Expand Up @@ -130,8 +133,11 @@ describe('test scope context', () => {
expect(result.current.count).toBe(2);
});

it('should static getState/getActions works', () => {
it('should static getState/getActions/getComputed works', () => {
const module = defineModule<State>({ count: 0 })
.computed({
doubled: (state) => state.count * 2,
})
.actions({
setCount: (draft, value) => (draft.count = value),
})
Expand All @@ -140,9 +146,10 @@ describe('test scope context', () => {
expect(module.getActions().setCount).toBeTypeOf('function');
module.getActions().setCount(2);
expect(module.getState().count).toBe(2);
expect(module.getComputed().doubled).toBe(4);
});

it('should static getState/getActions works under a scope', () => {
it('should static getState/getActions/getComputed works under a scope', () => {
const Provider = defineProvider((handle) => {
handle(module, {
defaultValue: { count: 123 },
Expand All @@ -152,6 +159,7 @@ describe('test scope context', () => {
const Counter = ({ testId }: { testId: string }) => {
const scope = useScopeContext();
const { count } = module.useState();
const { doubled } = module.useComputed();

const mutateCount = useCallback(() => {
// inner be 43
Expand All @@ -161,9 +169,12 @@ describe('test scope context', () => {
}, []);

return (
<button data-testid={testId} onClick={mutateCount}>
{count}
</button>
<>
<button data-testid={testId} onClick={mutateCount}>
{count}
</button>
<div data-testid={`${testId}-doubled`}>{doubled}</div>
</>
);
};

Expand All @@ -177,13 +188,19 @@ describe('test scope context', () => {
);

const outer = container.getByTestId('outer');
const outerDoubled = container.getByTestId('outer-doubled');
const inner = container.getByTestId('inner');
const innerDoubled = container.getByTestId('inner-doubled');
expect(outer.textContent).toBe('0');
expect(outerDoubled.textContent).toBe('0');
expect(inner.textContent).toBe('123');
expect(innerDoubled.textContent).toBe('246');
act(() => {
fireEvent.click(inner);
});
expect(outer.textContent).toBe('996');
expect(outerDoubled.textContent).toBe('1992');
expect(inner.textContent).toBe('43');
expect(innerDoubled.textContent).toBe('86');
});
});

0 comments on commit 9de9eeb

Please sign in to comment.