Skip to content

Commit ae742ad

Browse files
committed
fix(reactive): avoid chain reactions missing
1 parent 76a70ac commit ae742ad

File tree

7 files changed

+251
-19
lines changed

7 files changed

+251
-19
lines changed

packages/reactive/src/__tests__/autorun.spec.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,3 +757,207 @@ test('avoid unnecessary reaction', () => {
757757

758758
expect(fn1).toBeCalledTimes(1)
759759
})
760+
761+
test('avoid missing reaction', () => {
762+
const obs = observable<any>({
763+
res: 0,
764+
})
765+
766+
const fn1 = jest.fn()
767+
const fn2 = jest.fn()
768+
769+
let value
770+
autorun(() => {
771+
fn1()
772+
value = obs.res
773+
})
774+
775+
autorun(() => {
776+
fn2()
777+
obs.res
778+
if (obs.res !== 0) obs.res = obs.res + 1
779+
})
780+
781+
expect(value).toBe(0)
782+
783+
obs.res = 1
784+
785+
expect(value).toBe(2)
786+
787+
expect(fn1).toBeCalledTimes(3)
788+
expect(fn2).toBeCalledTimes(2)
789+
})
790+
791+
test('avoid reaction twice', () => {
792+
const obs = observable<any>({
793+
res: 0,
794+
})
795+
796+
const fn1 = jest.fn()
797+
const fn2 = jest.fn()
798+
799+
let value
800+
autorun(() => {
801+
fn1()
802+
obs.res
803+
if (obs.res !== 0) obs.res = obs.res + 1
804+
})
805+
806+
autorun(() => {
807+
fn2()
808+
value = obs.res
809+
})
810+
811+
expect(value).toBe(0)
812+
813+
obs.res = 1
814+
815+
expect(value).toBe(2)
816+
expect(fn1).toBeCalledTimes(2)
817+
expect(fn2).toBeCalledTimes(2)
818+
})
819+
820+
test('computed reaction', () => {
821+
const obs = observable<any>({
822+
aa: 1,
823+
bb: 1,
824+
})
825+
826+
const computed = observable.computed(() => {
827+
return obs.aa + obs.bb
828+
})
829+
830+
autorun(() => {
831+
if (obs.bb === 3) {
832+
obs.aa = 3
833+
}
834+
})
835+
836+
batch(() => {
837+
obs.aa = 2 // 会触发 computed 发生变化
838+
839+
obs.bb = 3
840+
})
841+
842+
expect(computed.value).toBe(6)
843+
})
844+
845+
test('accurate boundary', () => {
846+
const obs = observable<any>({
847+
a: '',
848+
b: '',
849+
c: '',
850+
})
851+
852+
autorun(() => {
853+
obs.c = obs.a + obs.b
854+
})
855+
856+
autorun(() => {
857+
obs.b = obs.a
858+
})
859+
860+
obs.a = 'a'
861+
expect(obs.a).toBe('a')
862+
expect(obs.b).toBe('a')
863+
expect(obs.c).toBe('aa')
864+
})
865+
866+
test('multiple source update', () => {
867+
const obs = observable<any>({})
868+
869+
const fn1 = jest.fn()
870+
const fn2 = jest.fn()
871+
872+
autorun(() => {
873+
const A = obs.A
874+
const B = obs.B
875+
if (A !== undefined && B !== undefined) {
876+
obs.C = A / B
877+
fn1()
878+
}
879+
})
880+
881+
autorun(() => {
882+
const C = obs.C
883+
const B = obs.B
884+
if (C !== undefined && B !== undefined) {
885+
obs.D = C * B
886+
fn2()
887+
}
888+
})
889+
890+
obs.A = 1
891+
obs.B = 2
892+
893+
expect(fn1).toBeCalledTimes(1)
894+
expect(fn2).toBeCalledTimes(1)
895+
})
896+
897+
test('same source in nest update', () => {
898+
const obs = observable<any>({})
899+
900+
const fn1 = jest.fn()
901+
902+
autorun(() => {
903+
const B = obs.B
904+
obs.B = 'B'
905+
fn1()
906+
return B
907+
})
908+
909+
obs.B = 'B2'
910+
911+
expect(fn1).toBeCalledTimes(2)
912+
})
913+
914+
test('batch execute autorun cause by deep indirect dependency', () => {
915+
const obs: any = observable({ aa: 1, bb: 1, cc: 1 })
916+
const fn = jest.fn()
917+
const fn2 = jest.fn()
918+
const fn3 = jest.fn()
919+
920+
autorun(() => fn((obs.aa = obs.bb + obs.cc)))
921+
autorun(() => fn2((obs.bb = obs.aa + obs.cc)))
922+
autorun(() => fn3((obs.cc = obs.aa + obs.bb)))
923+
924+
// 嵌套写法重复调用没意义,只需要确保最新被触发的 reaction 执行,已过时的 reaction 可以忽略
925+
// 比如 fn3 执行,触发 fn 和 fn2,fn 执行又触发 fn2,之前触发的 fn2 是过时的,忽略处理,fn2 只执行一次
926+
expect(fn).toBeCalledTimes(3)
927+
expect(fn2).toBeCalledTimes(2)
928+
expect(fn3).toBeCalledTimes(1)
929+
930+
fn.mockClear()
931+
fn2.mockClear()
932+
fn3.mockClear()
933+
934+
batch(() => {
935+
obs.aa = 100
936+
obs.bb = 100
937+
obs.cc = 100
938+
})
939+
940+
expect(fn).toBeCalledTimes(1)
941+
expect(fn2).toBeCalledTimes(1)
942+
expect(fn3).toBeCalledTimes(1)
943+
})
944+
945+
test('multiple update should trigger only one', () => {
946+
const obs = observable({ aa: 1, bb: 1 })
947+
948+
autorun(() => {
949+
obs.aa = obs.bb + 1
950+
obs.bb = obs.aa + 1
951+
})
952+
953+
expect(obs.aa).toBe(2)
954+
expect(obs.bb).toBe(3)
955+
956+
autorun(() => {
957+
obs.aa = obs.bb + 1
958+
obs.bb = obs.aa + 1
959+
})
960+
961+
expect(obs.aa).toBe(6)
962+
expect(obs.bb).toBe(7)
963+
})

packages/reactive/src/__tests__/tracker.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,5 @@ test('shared scheduler with multi tracker(mock react strict mode)', () => {
8989
obs.value = 123
9090

9191
expect(scheduler1).toBeCalledTimes(1)
92-
expect(scheduler2).toBeCalledTimes(0)
92+
expect(scheduler2).toBeCalledTimes(1)
9393
})

packages/reactive/src/array.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,15 @@ export class ArraySet<T> {
4949

5050
batchDelete(callback: (value: T) => void) {
5151
if (this.value.length === 0) return
52-
this.forEachIndex = 0
53-
for (; this.forEachIndex < this.value.length; this.forEachIndex++) {
54-
const value = this.value[this.forEachIndex]
55-
this.value.splice(this.forEachIndex, 1)
56-
this.forEachIndex--
57-
callback(value)
52+
53+
const batchList = this.value.splice(0, this.value.length)
54+
55+
for (let i = 0; i < batchList.length; i++) {
56+
callback(batchList[i])
57+
}
58+
59+
if (this.value.length > 0) {
60+
this.batchDelete(callback)
5861
}
5962
}
6063

packages/reactive/src/environment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const RawReactionsMap = new WeakMap<object, ReactionsMap>()
1212
export const ReactionStack: Reaction[] = []
1313
export const BatchCount = { value: 0 }
1414
export const UntrackCount = { value: 0 }
15+
export const BatchIdRef = { current: 1 }
1516
export const BatchScope = { value: false }
1617
export const DependencyCollected = { value: false }
1718
export const PendingReactions = new ReactionsArraySet<Reaction>()

packages/reactive/src/reaction.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ObserverListeners,
1616
PendingComputedReactions,
1717
PendingScopeComputedReactions,
18+
BatchIdRef,
1819
} from './environment'
1920

2021
const ITERATION_KEY = Symbol('iteration key')
@@ -224,13 +225,7 @@ export const batchScopeEnd = () => {
224225
})
225226

226227
BatchScope.value = false
227-
PendingScopeReactions.batchDelete((reaction) => {
228-
if (isFn(reaction._scheduler)) {
229-
reaction._scheduler(reaction)
230-
} else {
231-
reaction()
232-
}
233-
})
228+
executePendingScopeReactions()
234229
UntrackCount.value = prevUntrackCount
235230
}
236231

@@ -257,11 +252,29 @@ export const executePendingComputedReactions = () => {
257252
}
258253

259254
export const executePendingReactions = () => {
255+
const batchId = BatchIdRef.current++
260256
PendingReactions.batchDelete((reaction) => {
261-
if (isFn(reaction._scheduler)) {
262-
reaction._scheduler(reaction)
263-
} else {
264-
reaction()
257+
if (batchId > (reaction._batchId || 0)) {
258+
reaction._batchId = batchId
259+
if (isFn(reaction._scheduler)) {
260+
reaction._scheduler(reaction)
261+
} else {
262+
reaction()
263+
}
264+
}
265+
})
266+
}
267+
268+
export const executePendingScopeReactions = () => {
269+
const batchId = BatchIdRef.current++
270+
PendingScopeReactions.batchDelete((reaction) => {
271+
if (batchId > (reaction._batchId || 0)) {
272+
reaction._batchId = batchId
273+
if (isFn(reaction._scheduler)) {
274+
reaction._scheduler(reaction)
275+
} else {
276+
reaction()
277+
}
265278
}
266279
})
267280
}

packages/reactive/src/reactions-array-set.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,24 @@ export class ReactionsArraySet<T extends Reaction> {
5656
batchDelete(callback: (value: T) => void) {
5757
if (this.valueSet.size === 0) return
5858

59+
const list = []
60+
5961
for (const item of this.valueSet) {
6062
const reactionId = this.reactionIdMap.get(item)
6163
if (reactionId === item._reactionId) {
62-
callback(item)
64+
list.push(item)
6365
}
6466
}
6567

6668
this.clear()
69+
70+
for (let i = 0; i < list.length; i++) {
71+
callback(list[i])
72+
}
73+
74+
if (this.valueSet.size > 0) {
75+
this.batchDelete(callback)
76+
}
6777
}
6878

6979
clear() {

packages/reactive/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export type Reaction = ((...args: any[]) => any) & {
7272
_computesSet?: ArraySet<Reaction>
7373
_reactionsSet?: ArraySet<ReactionsArraySet<Reaction>>
7474
_reactionId?: number
75+
_batchId?: number
7576
_scheduler?: (reaction: Reaction) => void
7677
_memos?: {
7778
queue: IMemoQueueItem[]

0 commit comments

Comments
 (0)