-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathstrictAndConcurrentModeUsingTimers.test.tsx
More file actions
193 lines (156 loc) · 7.66 KB
/
Copy pathstrictAndConcurrentModeUsingTimers.test.tsx
File metadata and controls
193 lines (156 loc) · 7.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import "./utils/killFinalizationRegistry"
import { act, cleanup, render } from "@testing-library/react"
import * as mobx from "mobx"
import * as React from "react"
import { useObserver } from "../src/useObserver"
import {
REGISTRY_FINALIZE_AFTER,
REGISTRY_SWEEP_INTERVAL
} from "../src/utils/UniversalFinalizationRegistry"
import { observerFinalizationRegistry } from "../src/utils/observerFinalizationRegistry"
import { TimerBasedFinalizationRegistry } from "../src/utils/UniversalFinalizationRegistry"
expect(observerFinalizationRegistry).toBeInstanceOf(TimerBasedFinalizationRegistry)
const registry = observerFinalizationRegistry as TimerBasedFinalizationRegistry<unknown>
afterEach(cleanup)
test("uncommitted components should not leak observations", async () => {
registry.finalizeAllImmediately()
// Unfortunately, Jest fake timers don't mock out Date.now, so we fake
// that out in parallel to Jest useFakeTimers
let fakeNow = Date.now()
jest.useFakeTimers()
jest.spyOn(Date, "now").mockImplementation(() => fakeNow)
const store = mobx.observable({ count1: 0, count2: 0 })
// Track whether counts are observed
let count1IsObserved = false
let count2IsObserved = false
mobx.onBecomeObserved(store, "count1", () => (count1IsObserved = true))
mobx.onBecomeUnobserved(store, "count1", () => (count1IsObserved = false))
mobx.onBecomeObserved(store, "count2", () => (count2IsObserved = true))
mobx.onBecomeUnobserved(store, "count2", () => (count2IsObserved = false))
const TestComponent1 = () => useObserver(() => <div>{store.count1}</div>)
const TestComponent2 = () => useObserver(() => <div>{store.count2}</div>)
// Render, then remove only #2
const rendering = render(
<React.StrictMode>
<TestComponent1 />
<TestComponent2 />
</React.StrictMode>
)
rendering.rerender(
<React.StrictMode>
<TestComponent1 />
</React.StrictMode>
)
// Allow any reaction-disposal cleanup timers to run
const skip = Math.max(REGISTRY_FINALIZE_AFTER, REGISTRY_SWEEP_INTERVAL)
fakeNow += skip
jest.advanceTimersByTime(skip)
// count1 should still be being observed by Component1,
// but count2 should have had its reaction cleaned up.
expect(count1IsObserved).toBeTruthy()
expect(count2IsObserved).toBeFalsy()
})
test("cleanup timer should not clean up recently-pended reactions", () => {
// If we're not careful with timings, it's possible to get the
// following scenario:
// 1. Component instance A is being created; it renders, we put its reaction R1 into the cleanup list
// 2. Strict/Concurrent mode causes that render to be thrown away
// 3. Component instance A is being created; it renders, we put its reaction R2 into the cleanup list
// 4. The MobX reaction timer from 5 seconds ago kicks in and cleans up all reactions from uncommitted
// components, including R1 and R2
// 5. The commit phase runs for component A, but reaction R2 has already been disposed. Game over.
// This unit test attempts to replicate that scenario:
registry.finalizeAllImmediately()
// Unfortunately, Jest fake timers don't mock out Date.now, so we fake
// that out in parallel to Jest useFakeTimers
const fakeNow = Date.now()
jest.useFakeTimers()
jest.spyOn(Date, "now").mockImplementation(() => fakeNow)
const store = mobx.observable({ count: 0 })
// Track whether the count is observed
let countIsObserved = false
mobx.onBecomeObserved(store, "count", () => (countIsObserved = true))
mobx.onBecomeUnobserved(store, "count", () => (countIsObserved = false))
const TestComponent1 = () => useObserver(() => <div>{store.count}</div>)
const rendering = render(
// We use StrictMode here, but it would be helpful to switch this to use real
// concurrent mode: we don't have a true async render right now so this test
// isn't as thorough as it could be.
<React.StrictMode>
<TestComponent1 />
</React.StrictMode>
)
// We need to trigger our cleanup timer to run. We can't do this simply
// by running all jest's faked timers as that would allow the scheduled
// `useEffect` calls to run, and we want to simulate our cleanup timer
// getting in between those stages.
// We force our cleanup loop to run even though enough time hasn't _really_
// elapsed. In theory, it won't do anything because not enough time has
// elapsed since the reactions were queued, and so they won't be disposed.
registry.sweep()
// Advance time enough to allow any timer-queued effects to run
jest.advanceTimersByTime(500)
// Now allow the useEffect calls to run to completion.
act(() => {
// no-op, but triggers effect flushing
})
// count should still be observed
expect(countIsObserved).toBeTruthy()
})
// TODO: MWE: disabled during React 18 migration, not sure how to express it icmw with testing-lib,
// and using new React renderRoot will fail icmw JSDOM
test.skip("component should recreate reaction if necessary", () => {
// There _may_ be very strange cases where the reaction gets tidied up
// but is actually still needed. This _really_ shouldn't happen.
// e.g. if we're using Suspense and the component starts to render,
// but then gets paused for 60 seconds, and then comes back to life.
// With the implementation of React at the time of writing this, React
// will actually fully re-render that component (discarding previous
// hook slots) before going ahead with a commit, but it's unwise
// to depend on such an implementation detail. So we must cope with
// the component having had its reaction tidied and still going on to
// be committed. In that case we recreate the reaction and force
// an update.
// This unit test attempts to replicate that scenario:
registry.finalizeAllImmediately()
// Unfortunately, Jest fake timers don't mock out Date.now, so we fake
// that out in parallel to Jest useFakeTimers
let fakeNow = Date.now()
jest.useFakeTimers()
jest.spyOn(Date, "now").mockImplementation(() => fakeNow)
const store = mobx.observable({ count: 0 })
// Track whether the count is observed
let countIsObserved = false
mobx.onBecomeObserved(store, "count", () => (countIsObserved = true))
mobx.onBecomeUnobserved(store, "count", () => (countIsObserved = false))
const TestComponent1 = () => useObserver(() => <div>{store.count}</div>)
const rendering = render(
<React.StrictMode>
<TestComponent1 />
</React.StrictMode>
)
// We need to trigger our cleanup timer to run. We don't want
// to allow Jest's effects to run, however: we want to simulate the
// case where the component is rendered, then the reaction gets cleaned up,
// and _then_ the component commits.
// Force everything to be disposed.
const skip = Math.max(REGISTRY_FINALIZE_AFTER, REGISTRY_SWEEP_INTERVAL)
fakeNow += skip
registry.sweep()
// The reaction should have been cleaned up.
expect(countIsObserved).toBeFalsy()
// Whilst nobody's looking, change the observable value
store.count = 42
// Now allow the useEffect calls to run to completion,
// re-awakening the component.
jest.advanceTimersByTime(500)
act(() => {
// no-op, but triggers effect flushing
})
// count should be observed once more.
expect(countIsObserved).toBeTruthy()
// and the component should have rendered enough to
// show the latest value, which was set whilst it
// wasn't even looking.
expect(rendering.baseElement.textContent).toContain("42")
})