Skip to content

feat: add selector for useAyamami hook #453

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Ayanami, Effect, EffectAction, Reducer, useAyanami } from '../src'

interface State {
count: number
input: string
}

interface TipsState {
Expand All @@ -30,6 +31,7 @@ class Tips extends Ayanami<TipsState> {
class Count extends Ayanami<State> {
defaultState = {
count: 0,
input: '',
}

otherProps = ''
Expand All @@ -40,17 +42,22 @@ class Count extends Ayanami<State> {

@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()
Expand All @@ -67,7 +74,7 @@ class Count extends Ayanami<State> {
}

function CountComponent() {
const [{ count }, actions] = useAyanami(Count)
const [{ count, input }, actions] = useAyanami(Count)
const [{ tips }] = useAyanami(Tips)

const add = (count: number) => () => actions.add(count)
Expand All @@ -76,12 +83,26 @@ function CountComponent() {
return (
<div>
<p>count: {count}</p>
<p>input: {input}</p>
<p>tips: {tips}</p>
<button onClick={add(1)}>add one</button>
<button onClick={minus(1)}>minus one</button>
<button onClick={actions.reset}>reset to zero</button>
<button onClick={actions.reset}>reset</button>
<InputComponent />
</div>
)
}

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

return (
<div>
<h3>{input}</h3>
<input value={input} onChange={(e) => actions.changeInput(e.target.value)} />
</div>
)
})
InputComponent.displayName = 'InputComponent'

ReactDOM.render(<CountComponent />, document.querySelector('#app'))
10 changes: 10 additions & 0 deletions src/hooks/shallow-equal.ts
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 8 additions & 14 deletions src/hooks/use-ayanami-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S, U> {
destroyWhenUnmount?: boolean
selector?: (state: S) => U
}

export type UseAyanamiInstanceResult<M extends Ayanami<S>, S> = [
Readonly<S>,
ActionMethodOfAyanami<M, S>,
]
export type UseAyanamiInstanceResult<M extends Ayanami<S>, S, U> = [U, ActionMethodOfAyanami<M, S>]

type Config = UseAyanamiInstanceConfig

type Result<M extends Ayanami<S>, S> = UseAyanamiInstanceResult<M, S>

export function useAyanamiInstance<M extends Ayanami<S>, S>(
export function useAyanamiInstance<M extends Ayanami<S>, S, U>(
ayanami: M,
config?: Config,
): Result<M, S> {
config?: UseAyanamiInstanceConfig<S, U>,
): UseAyanamiInstanceResult<M, S, U> {
const ikari = React.useMemo(() => combineWithIkari(ayanami), [ayanami])
const state = useSubscribeAyanamiState(ayanami)
const state = useSubscribeAyanamiState(ayanami, config ? config.selector : undefined)

React.useEffect(
() => () => {
Expand All @@ -35,5 +29,5 @@ export function useAyanamiInstance<M extends Ayanami<S>, S>(
[ayanami, config],
)

return [state, ikari.triggerActions] as Result<M, S>
return [state, ikari.triggerActions] as UseAyanamiInstanceResult<M, S, U>
}
19 changes: 12 additions & 7 deletions src/hooks/use-ayanami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,28 @@ import { SSRContext } from '../ssr/ssr-context'

import {
useAyanamiInstance,
UseAyanamiInstanceResult,
UseAyanamiInstanceResult as Result,
UseAyanamiInstanceConfig,
} from './use-ayanami-instance'

export function useAyanami<M extends Ayanami<S>, S>(
interface Config<S, U> extends Partial<ScopeConfig> {
selector?: (state: S) => U
}

export function useAyanami<M extends Ayanami<S>, S, U = M extends Ayanami<infer SS> ? SS : S>(
A: ConstructorOf<M>,
config?: ScopeConfig,
): M extends Ayanami<infer SS> ? UseAyanamiInstanceResult<M, SS> : UseAyanamiInstanceResult<M, S> {
config?: M extends Ayanami<infer SS> ? Config<SS, U> : Config<S, U>,
): M extends Ayanami<infer SS> ? Result<M, SS, U> : Result<M, S, U> {
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<S, U> => {
return { destroyWhenUnmount: scope === TransientScope, selector }
}, [reqScope])

return useAyanamiInstance<M, S>(ayanami, useAyanamiInstanceConfig) as any
return useAyanamiInstance<M, S, U>(ayanami, useAyanamiInstanceConfig) as any
}
22 changes: 17 additions & 5 deletions src/hooks/use-subscribe-ayanami-state.ts
Original file line number Diff line number Diff line change
@@ -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<M extends Ayanami<S>, S>(ayanami: M): S {
export function useSubscribeAyanamiState<M extends Ayanami<S>, S, U>(
ayanami: M,
selector: (state: S) => U = identity,
): unknown {
const state = ayanami.getState()

const ayanamiRef = React.useRef<Ayanami<S> | null>(null)
const subscriptionRef = React.useRef<Subscription | null>(null)
const stateRef = React.useRef<S>(state)

const [state, setState] = React.useState<S>(() => ayanami.getState())
const [, forceUpdate] = React.useState({})

if (ayanamiRef.current !== ayanami) {
ayanamiRef.current = ayanami
Expand All @@ -18,7 +25,12 @@ export function useSubscribeAyanamiState<M extends Ayanami<S>, 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
})
}
}

Expand All @@ -31,5 +43,5 @@ export function useSubscribeAyanamiState<M extends Ayanami<S>, S>(ayanami: M): S
[subscriptionRef],
)

return state
return selector(state)
}
29 changes: 29 additions & 0 deletions test/specs/hooks.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useCallback, useEffect } from 'react'

interface State {
count: number
anotherCount: number
}

enum CountAction {
Expand All @@ -27,6 +28,7 @@ const numberProvider: ValueProvider = {
class Count extends Ayanami<State> {
defaultState = {
count: -1,
anotherCount: 0,
}

constructor(@Inject(numberProvider.provide) number: number) {
Expand Down Expand Up @@ -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 <div />
})

const OuterComponent = () => {
const [state, actions] = useAyanami(Count, { scope: TransientScope })
const addOne = useCallback(() => actions.add(1), [])
outerRenderSpy(state.count)
return (
<div>
<button onClick={addOne}>add one</button>
<InnerComponent />
</div>
)
}
const renderer = create(<OuterComponent />)
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 = () => {
Expand Down