Skip to content

Commit e4b2a2c

Browse files
authored
Merge pull request #830 from sigi-framework/fix/selector-closure-steal
fix(react): state selector may use old closures
2 parents 3ff8a62 + 69b19ce commit e4b2a2c

File tree

4 files changed

+36
-35
lines changed

4 files changed

+36
-35
lines changed

packages/core/src/store.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export class Store<S> implements IStore<S> {
1717
private readonly reducer: Reducer<S, Action>
1818
private readonly epic$: BehaviorSubject<Epic>
1919
private actionSub = new Subscription()
20-
private readonly stateSub = new Subscription()
2120
private readonly initAction: Action<null> = {
2221
type: INIT_ACTION_TYPE_SYMBOL,
2322
payload: null,
@@ -47,14 +46,8 @@ export class Store<S> implements IStore<S> {
4746
*/
4847
setup(defaultState: S) {
4948
this.internalState = defaultState
50-
49+
this.state$.next(defaultState)
5150
this.subscribeAction()
52-
this.stateSub.add(
53-
this.state$.subscribe((state) => {
54-
this.internalState = state
55-
}),
56-
)
57-
this.state$.next(this.state)
5851
this.log(this.initAction)
5952
this.isReady = true
6053
}
@@ -101,6 +94,7 @@ export class Store<S> implements IStore<S> {
10194
if (process.env.NODE_ENV !== 'production' && newState === undefined) {
10295
console.warn(`${action.type} produced an undefined state, you may forget to return new State in @Reducer`)
10396
}
97+
this.internalState = newState
10498
this.state$.next(newState)
10599
}
106100
this.log(action)
@@ -114,7 +108,6 @@ export class Store<S> implements IStore<S> {
114108
}
115109

116110
dispose() {
117-
this.stateSub.unsubscribe()
118111
this.actionSub.unsubscribe()
119112
this.action$.complete()
120113
this.state$.complete()

packages/react/src/__tests__/react.spec.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@ describe('React components test', () => {
6565
const TestComponent = () => {
6666
const [localCount, setLocalCount] = useState(0)
6767
const state = useModuleState(CountModel, {
68-
selector: (state) => state.count + localCount,
68+
selector: (state) => {
69+
return state.count + localCount
70+
},
6971
dependencies: [localCount],
7072
})
71-
spy()
73+
spy(state)
7274
stub.callsFake(() => {
7375
setLocalCount(localCount + 1)
7476
})
@@ -87,7 +89,8 @@ describe('React components test', () => {
8789
it('should render three times while change local state which in dependencies list', () => {
8890
const reactNode = create(<TestComponent />)
8991
act(() => stub())
90-
expect(spy.callCount).toBe(3)
92+
expect(spy.callCount).toBe(2)
93+
expect(spy.args).toEqual([[0], [1]])
9194
expect(reactNode).toMatchSnapshot()
9295
})
9396
})

packages/react/src/index.browser.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { EffectModule, ActionOfEffectModule } from '@sigi/core'
22
import { ConstructorOf, IStore } from '@sigi/types'
33
import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
4-
import { distinctUntilChanged, map, skip } from 'rxjs/operators'
4+
import { identity } from 'rxjs'
5+
import { skip, tap } from 'rxjs/operators'
56

67
import { useInstance } from './injectable-context'
78
import { shallowEqual } from './shallow-equal'
@@ -32,38 +33,41 @@ function _useModuleState<S, U = S>(
3233
}
3334
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
3435
dependencies = dependencies || []
35-
const isFirstRendering = useRef(true)
36-
const [appState, setState] = useState(() => {
37-
const initialState = store.state
38-
return selector ? selector(initialState) : initialState
39-
})
40-
41-
const subscribe = useCallback(() => {
42-
return store.state$
43-
.pipe(
44-
map((s) => (selector ? selector(s) : s)),
45-
distinctUntilChanged((s1, s2) => equalFn(s1, s2)),
46-
// skip initial state emission
47-
skip(isFirstRendering.current ? 1 : 0),
48-
)
49-
.subscribe(setState)
50-
// eslint-disable-next-line react-hooks/exhaustive-deps
51-
}, [store, ...dependencies])
36+
const stateRef = useRef<S | U>()
37+
const depsRef = useRef<any[]>()
38+
const selectorRef = useRef<(s: S) => S | U>()
39+
40+
if (!equalFn(depsRef.current, dependencies)) {
41+
selectorRef.current = selector ?? identity
42+
stateRef.current = selectorRef.current(store.state)
43+
}
44+
depsRef.current = dependencies
5245

53-
const subscription = useMemo(() => {
54-
return subscribe()
46+
const [_, _flipSig] = useState(false)
47+
48+
const tryUpdateState = useCallback((state: S) => {
49+
const newState = selectorRef.current!(state)
50+
if (!equalFn(stateRef.current, newState)) {
51+
stateRef.current = newState
52+
_flipSig((v) => !v)
53+
}
5554
// eslint-disable-next-line react-hooks/exhaustive-deps
56-
}, [subscribe])
55+
}, [])
56+
57+
const subscribe = useCallback(
58+
() => store.state$.pipe(skip(1), tap(tryUpdateState)).subscribe(),
59+
[store, tryUpdateState],
60+
)
61+
const subscription = useMemo(() => subscribe(), [subscribe])
5762

5863
useEffect(() => {
59-
isFirstRendering.current = false
6064
const maybeReSubscribeInConcurrencyMode = subscription.closed ? subscribe() : subscription
6165
return () => {
6266
maybeReSubscribeInConcurrencyMode.unsubscribe()
6367
}
6468
}, [subscription, subscribe])
6569

66-
return appState
70+
return stateRef.current!
6771
}
6872

6973
export function useModuleState<M extends EffectModule<any>>(

packages/vue/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export const reactive = <
103103
}
104104
}
105105
if (changed) {
106+
store['internalState'] = latestState
106107
store.state$.next(latestState)
107108
}
108109
if (typeof originalBeforeUpdate === 'function') {

0 commit comments

Comments
 (0)