Skip to content

Commit 5b8ffde

Browse files
authored
Merge pull request #453 from InfiniteXyy/master
feat: add selector for useAyamami hook
2 parents b45d06c + 6e13def commit 5b8ffde

File tree

6 files changed

+102
-31
lines changed

6 files changed

+102
-31
lines changed

demo/index.tsx

+26-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Ayanami, Effect, EffectAction, Reducer, useAyanami } from '../src'
88

99
interface State {
1010
count: number
11+
input: string
1112
}
1213

1314
interface TipsState {
@@ -30,6 +31,7 @@ class Tips extends Ayanami<TipsState> {
3031
class Count extends Ayanami<State> {
3132
defaultState = {
3233
count: 0,
34+
input: '',
3335
}
3436

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

4143
@Reducer()
4244
add(state: State, count: number): State {
43-
return { count: state.count + count }
45+
return { ...state, count: state.count + count }
4446
}
4547

4648
@Reducer()
4749
addOne(state: State): State {
48-
return { count: state.count + 1 }
50+
return { ...state, count: state.count + 1 }
4951
}
5052

5153
@Reducer()
5254
reset(): State {
53-
return { count: 0 }
55+
return { count: 0, input: '' }
56+
}
57+
58+
@Reducer()
59+
changeInput(state: State, value: string): State {
60+
return { ...state, input: value }
5461
}
5562

5663
@Effect()
@@ -67,7 +74,7 @@ class Count extends Ayanami<State> {
6774
}
6875

6976
function CountComponent() {
70-
const [{ count }, actions] = useAyanami(Count)
77+
const [{ count, input }, actions] = useAyanami(Count)
7178
const [{ tips }] = useAyanami(Tips)
7279

7380
const add = (count: number) => () => actions.add(count)
@@ -76,12 +83,26 @@ function CountComponent() {
7683
return (
7784
<div>
7885
<p>count: {count}</p>
86+
<p>input: {input}</p>
7987
<p>tips: {tips}</p>
8088
<button onClick={add(1)}>add one</button>
8189
<button onClick={minus(1)}>minus one</button>
82-
<button onClick={actions.reset}>reset to zero</button>
90+
<button onClick={actions.reset}>reset</button>
91+
<InputComponent />
8392
</div>
8493
)
8594
}
8695

96+
const InputComponent = React.memo(() => {
97+
const [input, actions] = useAyanami(Count, { selector: (state) => state.input })
98+
99+
return (
100+
<div>
101+
<h3>{input}</h3>
102+
<input value={input} onChange={(e) => actions.changeInput(e.target.value)} />
103+
</div>
104+
)
105+
})
106+
InputComponent.displayName = 'InputComponent'
107+
87108
ReactDOM.render(<CountComponent />, document.querySelector('#app'))

src/hooks/shallow-equal.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const shallowEqual = (a: any, b: any): boolean => {
2+
if (a === b) return true
3+
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
4+
return (
5+
Object.keys(a).length === Object.keys(b).length &&
6+
Object.keys(a).every((key) => a[key] === b[key])
7+
)
8+
}
9+
return false
10+
}

src/hooks/use-ayanami-instance.ts

+8-14
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,19 @@ import get from 'lodash/get'
44
import { ActionMethodOfAyanami, Ayanami, combineWithIkari } from '../core'
55
import { useSubscribeAyanamiState } from './use-subscribe-ayanami-state'
66

7-
export interface UseAyanamiInstanceConfig {
7+
export interface UseAyanamiInstanceConfig<S, U> {
88
destroyWhenUnmount?: boolean
9+
selector?: (state: S) => U
910
}
1011

11-
export type UseAyanamiInstanceResult<M extends Ayanami<S>, S> = [
12-
Readonly<S>,
13-
ActionMethodOfAyanami<M, S>,
14-
]
12+
export type UseAyanamiInstanceResult<M extends Ayanami<S>, S, U> = [U, ActionMethodOfAyanami<M, S>]
1513

16-
type Config = UseAyanamiInstanceConfig
17-
18-
type Result<M extends Ayanami<S>, S> = UseAyanamiInstanceResult<M, S>
19-
20-
export function useAyanamiInstance<M extends Ayanami<S>, S>(
14+
export function useAyanamiInstance<M extends Ayanami<S>, S, U>(
2115
ayanami: M,
22-
config?: Config,
23-
): Result<M, S> {
16+
config?: UseAyanamiInstanceConfig<S, U>,
17+
): UseAyanamiInstanceResult<M, S, U> {
2418
const ikari = React.useMemo(() => combineWithIkari(ayanami), [ayanami])
25-
const state = useSubscribeAyanamiState(ayanami)
19+
const state = useSubscribeAyanamiState(ayanami, config ? config.selector : undefined)
2620

2721
React.useEffect(
2822
() => () => {
@@ -35,5 +29,5 @@ export function useAyanamiInstance<M extends Ayanami<S>, S>(
3529
[ayanami, config],
3630
)
3731

38-
return [state, ikari.triggerActions] as Result<M, S>
32+
return [state, ikari.triggerActions] as UseAyanamiInstanceResult<M, S, U>
3933
}

src/hooks/use-ayanami.ts

+12-7
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,28 @@ import { SSRContext } from '../ssr/ssr-context'
1515

1616
import {
1717
useAyanamiInstance,
18-
UseAyanamiInstanceResult,
18+
UseAyanamiInstanceResult as Result,
1919
UseAyanamiInstanceConfig,
2020
} from './use-ayanami-instance'
2121

22-
export function useAyanami<M extends Ayanami<S>, S>(
22+
interface Config<S, U> extends Partial<ScopeConfig> {
23+
selector?: (state: S) => U
24+
}
25+
26+
export function useAyanami<M extends Ayanami<S>, S, U = M extends Ayanami<infer SS> ? SS : S>(
2327
A: ConstructorOf<M>,
24-
config?: ScopeConfig,
25-
): M extends Ayanami<infer SS> ? UseAyanamiInstanceResult<M, SS> : UseAyanamiInstanceResult<M, S> {
28+
config?: M extends Ayanami<infer SS> ? Config<SS, U> : Config<S, U>,
29+
): M extends Ayanami<infer SS> ? Result<M, SS, U> : Result<M, S, U> {
2630
const scope = get(config, 'scope')
31+
const selector = get(config, 'selector')
2732
const req = isSSREnabled() ? React.useContext(SSRContext) : null
2833
const reqScope = req ? createScopeWithRequest(req, scope) : scope
2934
const ayanami = React.useMemo(() => getInstanceWithScope(A, reqScope), [reqScope])
3035
ayanami.scopeName = scope || DEFAULT_SCOPE_NAME
3136

32-
const useAyanamiInstanceConfig = React.useMemo((): UseAyanamiInstanceConfig => {
33-
return { destroyWhenUnmount: scope === TransientScope }
37+
const useAyanamiInstanceConfig = React.useMemo((): UseAyanamiInstanceConfig<S, U> => {
38+
return { destroyWhenUnmount: scope === TransientScope, selector }
3439
}, [reqScope])
3540

36-
return useAyanamiInstance<M, S>(ayanami, useAyanamiInstanceConfig) as any
41+
return useAyanamiInstance<M, S, U>(ayanami, useAyanamiInstanceConfig) as any
3742
}

src/hooks/use-subscribe-ayanami-state.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import * as React from 'react'
22
import { Subscription } from 'rxjs'
3-
3+
import identity from 'lodash/identity'
4+
import { shallowEqual } from './shallow-equal'
45
import { Ayanami } from '../core'
56

6-
export function useSubscribeAyanamiState<M extends Ayanami<S>, S>(ayanami: M): S {
7+
export function useSubscribeAyanamiState<M extends Ayanami<S>, S, U>(
8+
ayanami: M,
9+
selector: (state: S) => U = identity,
10+
): unknown {
11+
const state = ayanami.getState()
12+
713
const ayanamiRef = React.useRef<Ayanami<S> | null>(null)
814
const subscriptionRef = React.useRef<Subscription | null>(null)
15+
const stateRef = React.useRef<S>(state)
916

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

1219
if (ayanamiRef.current !== ayanami) {
1320
ayanamiRef.current = ayanami
@@ -18,7 +25,12 @@ export function useSubscribeAyanamiState<M extends Ayanami<S>, S>(ayanami: M): S
1825
}
1926

2027
if (ayanami) {
21-
subscriptionRef.current = ayanami.getState$().subscribe(setState)
28+
subscriptionRef.current = ayanami.getState$().subscribe((state) => {
29+
const before = selector(stateRef.current)
30+
const after = selector(state)
31+
if (!shallowEqual(before, after)) forceUpdate({})
32+
stateRef.current = state
33+
})
2234
}
2335
}
2436

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

34-
return state
46+
return selector(state)
3547
}

test/specs/hooks.spec.tsx

+29
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useCallback, useEffect } from 'react'
99

1010
interface State {
1111
count: number
12+
anotherCount: number
1213
}
1314

1415
enum CountAction {
@@ -27,6 +28,7 @@ const numberProvider: ValueProvider = {
2728
class Count extends Ayanami<State> {
2829
defaultState = {
2930
count: -1,
31+
anotherCount: 0,
3032
}
3133

3234
constructor(@Inject(numberProvider.provide) number: number) {
@@ -98,6 +100,33 @@ describe('Hooks spec:', () => {
98100
expect(count()).toBe('0')
99101
})
100102

103+
it('State selector work properly', () => {
104+
const innerRenderSpy = jest.fn()
105+
const outerRenderSpy = jest.fn()
106+
107+
const InnerComponent = React.memo(({ scope }: { scope?: any }) => {
108+
const [anotherCount] = useAyanami(Count, { selector: (state) => state.anotherCount, scope })
109+
innerRenderSpy(anotherCount)
110+
return <div />
111+
})
112+
113+
const OuterComponent = () => {
114+
const [state, actions] = useAyanami(Count, { scope: TransientScope })
115+
const addOne = useCallback(() => actions.add(1), [])
116+
outerRenderSpy(state.count)
117+
return (
118+
<div>
119+
<button onClick={addOne}>add one</button>
120+
<InnerComponent />
121+
</div>
122+
)
123+
}
124+
const renderer = create(<OuterComponent />)
125+
act(() => renderer.root.findByType('button').props.onClick())
126+
expect(innerRenderSpy.mock.calls).toEqual([[0]])
127+
expect(outerRenderSpy.mock.calls).toEqual([[0], [1]])
128+
})
129+
101130
it('should only render once when update the state right during rendering', () => {
102131
const spy = jest.fn()
103132
const TestComponent = () => {

0 commit comments

Comments
 (0)