diff --git a/demo/index.tsx b/demo/index.tsx index 27832d51..0e9efcd9 100644 --- a/demo/index.tsx +++ b/demo/index.tsx @@ -8,6 +8,7 @@ import { Ayanami, Effect, EffectAction, Reducer, useAyanami } from '../src' interface State { count: number + input: string } interface TipsState { @@ -30,6 +31,7 @@ class Tips extends Ayanami { class Count extends Ayanami { defaultState = { count: 0, + input: '', } otherProps = '' @@ -40,17 +42,22 @@ class Count extends Ayanami { @Reducer() add(state: State, count: number): State { - return { count: state.count + count } + return { ...state, count: state.count + count } } @Reducer() addOne(state: State): State { - return { count: state.count + 1 } + return { ...state, count: state.count + 1 } } @Reducer() reset(): State { - return { count: 0 } + return { count: 0, input: '' } + } + + @Reducer() + changeInput(state: State, value: string): State { + return { ...state, input: value } } @Effect() @@ -67,7 +74,7 @@ class Count extends Ayanami { } function CountComponent() { - const [{ count }, actions] = useAyanami(Count) + const [{ count, input }, actions] = useAyanami(Count) const [{ tips }] = useAyanami(Tips) const add = (count: number) => () => actions.add(count) @@ -76,12 +83,26 @@ function CountComponent() { return (

count: {count}

+

input: {input}

tips: {tips}

- + +
) } +const InputComponent = React.memo(() => { + const [input, actions] = useAyanami(Count, { selector: (state) => state.input }) + + return ( +
+

{input}

+ actions.changeInput(e.target.value)} /> +
+ ) +}) +InputComponent.displayName = 'InputComponent' + ReactDOM.render(, document.querySelector('#app')) diff --git a/src/hooks/shallow-equal.ts b/src/hooks/shallow-equal.ts new file mode 100644 index 00000000..462712a5 --- /dev/null +++ b/src/hooks/shallow-equal.ts @@ -0,0 +1,10 @@ +export const shallowEqual = (a: any, b: any): boolean => { + if (a === b) return true + if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + return ( + Object.keys(a).length === Object.keys(b).length && + Object.keys(a).every((key) => a[key] === b[key]) + ) + } + return false +} diff --git a/src/hooks/use-ayanami-instance.ts b/src/hooks/use-ayanami-instance.ts index c39ce8d3..889932a4 100644 --- a/src/hooks/use-ayanami-instance.ts +++ b/src/hooks/use-ayanami-instance.ts @@ -4,25 +4,19 @@ import get from 'lodash/get' import { ActionMethodOfAyanami, Ayanami, combineWithIkari } from '../core' import { useSubscribeAyanamiState } from './use-subscribe-ayanami-state' -export interface UseAyanamiInstanceConfig { +export interface UseAyanamiInstanceConfig { destroyWhenUnmount?: boolean + selector?: (state: S) => U } -export type UseAyanamiInstanceResult, S> = [ - Readonly, - ActionMethodOfAyanami, -] +export type UseAyanamiInstanceResult, S, U> = [U, ActionMethodOfAyanami] -type Config = UseAyanamiInstanceConfig - -type Result, S> = UseAyanamiInstanceResult - -export function useAyanamiInstance, S>( +export function useAyanamiInstance, S, U>( ayanami: M, - config?: Config, -): Result { + config?: UseAyanamiInstanceConfig, +): UseAyanamiInstanceResult { const ikari = React.useMemo(() => combineWithIkari(ayanami), [ayanami]) - const state = useSubscribeAyanamiState(ayanami) + const state = useSubscribeAyanamiState(ayanami, config ? config.selector : undefined) React.useEffect( () => () => { @@ -35,5 +29,5 @@ export function useAyanamiInstance, S>( [ayanami, config], ) - return [state, ikari.triggerActions] as Result + return [state, ikari.triggerActions] as UseAyanamiInstanceResult } diff --git a/src/hooks/use-ayanami.ts b/src/hooks/use-ayanami.ts index 90dbe387..002addcb 100644 --- a/src/hooks/use-ayanami.ts +++ b/src/hooks/use-ayanami.ts @@ -15,23 +15,28 @@ import { SSRContext } from '../ssr/ssr-context' import { useAyanamiInstance, - UseAyanamiInstanceResult, + UseAyanamiInstanceResult as Result, UseAyanamiInstanceConfig, } from './use-ayanami-instance' -export function useAyanami, S>( +interface Config extends Partial { + selector?: (state: S) => U +} + +export function useAyanami, S, U = M extends Ayanami ? SS : S>( A: ConstructorOf, - config?: ScopeConfig, -): M extends Ayanami ? UseAyanamiInstanceResult : UseAyanamiInstanceResult { + config?: M extends Ayanami ? Config : Config, +): M extends Ayanami ? Result : Result { const scope = get(config, 'scope') + const selector = get(config, 'selector') const req = isSSREnabled() ? React.useContext(SSRContext) : null const reqScope = req ? createScopeWithRequest(req, scope) : scope const ayanami = React.useMemo(() => getInstanceWithScope(A, reqScope), [reqScope]) ayanami.scopeName = scope || DEFAULT_SCOPE_NAME - const useAyanamiInstanceConfig = React.useMemo((): UseAyanamiInstanceConfig => { - return { destroyWhenUnmount: scope === TransientScope } + const useAyanamiInstanceConfig = React.useMemo((): UseAyanamiInstanceConfig => { + return { destroyWhenUnmount: scope === TransientScope, selector } }, [reqScope]) - return useAyanamiInstance(ayanami, useAyanamiInstanceConfig) as any + return useAyanamiInstance(ayanami, useAyanamiInstanceConfig) as any } diff --git a/src/hooks/use-subscribe-ayanami-state.ts b/src/hooks/use-subscribe-ayanami-state.ts index 6f21acff..2d978011 100644 --- a/src/hooks/use-subscribe-ayanami-state.ts +++ b/src/hooks/use-subscribe-ayanami-state.ts @@ -1,13 +1,20 @@ import * as React from 'react' import { Subscription } from 'rxjs' - +import identity from 'lodash/identity' +import { shallowEqual } from './shallow-equal' import { Ayanami } from '../core' -export function useSubscribeAyanamiState, S>(ayanami: M): S { +export function useSubscribeAyanamiState, S, U>( + ayanami: M, + selector: (state: S) => U = identity, +): unknown { + const state = ayanami.getState() + const ayanamiRef = React.useRef | null>(null) const subscriptionRef = React.useRef(null) + const stateRef = React.useRef(state) - const [state, setState] = React.useState(() => ayanami.getState()) + const [, forceUpdate] = React.useState({}) if (ayanamiRef.current !== ayanami) { ayanamiRef.current = ayanami @@ -18,7 +25,12 @@ export function useSubscribeAyanamiState, S>(ayanami: M): S } if (ayanami) { - subscriptionRef.current = ayanami.getState$().subscribe(setState) + subscriptionRef.current = ayanami.getState$().subscribe((state) => { + const before = selector(stateRef.current) + const after = selector(state) + if (!shallowEqual(before, after)) forceUpdate({}) + stateRef.current = state + }) } } @@ -31,5 +43,5 @@ export function useSubscribeAyanamiState, S>(ayanami: M): S [subscriptionRef], ) - return state + return selector(state) } diff --git a/test/specs/hooks.spec.tsx b/test/specs/hooks.spec.tsx index 0ecc1c66..dea500fb 100644 --- a/test/specs/hooks.spec.tsx +++ b/test/specs/hooks.spec.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect } from 'react' interface State { count: number + anotherCount: number } enum CountAction { @@ -27,6 +28,7 @@ const numberProvider: ValueProvider = { class Count extends Ayanami { defaultState = { count: -1, + anotherCount: 0, } constructor(@Inject(numberProvider.provide) number: number) { @@ -98,6 +100,33 @@ describe('Hooks spec:', () => { expect(count()).toBe('0') }) + it('State selector work properly', () => { + const innerRenderSpy = jest.fn() + const outerRenderSpy = jest.fn() + + const InnerComponent = React.memo(({ scope }: { scope?: any }) => { + const [anotherCount] = useAyanami(Count, { selector: (state) => state.anotherCount, scope }) + innerRenderSpy(anotherCount) + return
+ }) + + const OuterComponent = () => { + const [state, actions] = useAyanami(Count, { scope: TransientScope }) + const addOne = useCallback(() => actions.add(1), []) + outerRenderSpy(state.count) + return ( +
+ + +
+ ) + } + const renderer = create() + act(() => renderer.root.findByType('button').props.onClick()) + expect(innerRenderSpy.mock.calls).toEqual([[0]]) + expect(outerRenderSpy.mock.calls).toEqual([[0], [1]]) + }) + it('should only render once when update the state right during rendering', () => { const spy = jest.fn() const TestComponent = () => {