| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @emails react-core |
| 8 | * @jest-environment node |
| 9 | */ |
| 10 | |
| 11 | 'use strict'; |
| 12 | |
| 13 | let React; |
| 14 | let ReactNoop; |
| 15 | let Scheduler; |
| 16 | let act; |
| 17 | let waitForAll; |
| 18 | let waitFor; |
| 19 | let assertLog; |
| 20 | let waitForPaint; |
| 21 | |
| 22 | describe('ReactIncrementalScheduling', () => { |
| 23 | beforeEach(() => { |
| 24 | jest.resetModules(); |
| 25 | |
| 26 | React = require('react'); |
| 27 | ReactNoop = require('react-noop-renderer'); |
| 28 | Scheduler = require('scheduler'); |
| 29 | act = require('internal-test-utils').act; |
| 30 | |
| 31 | const InternalTestUtils = require('internal-test-utils'); |
| 32 | waitForAll = InternalTestUtils.waitForAll; |
| 33 | waitFor = InternalTestUtils.waitFor; |
| 34 | assertLog = InternalTestUtils.assertLog; |
| 35 | waitForPaint = InternalTestUtils.waitForPaint; |
| 36 | }); |
| 37 | |
| 38 | it('schedules and flushes deferred work', async () => { |
| 39 | ReactNoop.render(<span prop="1" />); |
| 40 | expect(ReactNoop).toMatchRenderedOutput(null); |
| 41 | |
| 42 | await waitForAll([]); |
| 43 | expect(ReactNoop).toMatchRenderedOutput(<span prop="1" />); |
| 44 | }); |
| 45 | |
| 46 | it('searches for work on other roots once the current root completes', async () => { |
| 47 | ReactNoop.renderToRootWithID(<span prop="a:1" />, 'a'); |
| 48 | ReactNoop.renderToRootWithID(<span prop="b:1" />, 'b'); |
| 49 | ReactNoop.renderToRootWithID(<span prop="c:1" />, 'c'); |
| 50 | |
| 51 | await waitForAll([]); |
| 52 | |
| 53 | expect(ReactNoop.getChildrenAsJSX('a')).toEqual(<span prop="a:1" />); |
| 54 | expect(ReactNoop.getChildrenAsJSX('b')).toEqual(<span prop="b:1" />); |
| 55 | expect(ReactNoop.getChildrenAsJSX('c')).toEqual(<span prop="c:1" />); |
| 56 | }); |
| 57 | |
| 58 | it('schedules top-level updates in order of priority', async () => { |
| 59 | // Initial render. |
| 60 | ReactNoop.render(<span prop={1} />); |
| 61 | await waitForAll([]); |
| 62 | expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />); |
| 63 | |
| 64 | ReactNoop.batchedUpdates(() => { |
| 65 | ReactNoop.render(<span prop={5} />); |
| 66 | ReactNoop.flushSync(() => { |
| 67 | ReactNoop.render(<span prop={2} />); |
| 68 | ReactNoop.render(<span prop={3} />); |
| 69 | ReactNoop.render(<span prop={4} />); |
| 70 | }); |
| 71 | }); |
| 72 | // The sync updates flush first. |
| 73 | expect(ReactNoop).toMatchRenderedOutput(<span prop={4} />); |
| 74 | |
| 75 | // The terminal value should be the last update that was scheduled, |
| 76 | // regardless of priority. In this case, that's the last sync update. |
| 77 | await waitForAll([]); |
| 78 | expect(ReactNoop).toMatchRenderedOutput(<span prop={4} />); |
| 79 | }); |
| 80 | |
| 81 | it('schedules top-level updates with same priority in order of insertion', async () => { |
| 82 | // Initial render. |
| 83 | ReactNoop.render(<span prop={1} />); |
| 84 | await waitForAll([]); |
| 85 | expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />); |
| 86 | |
| 87 | ReactNoop.render(<span prop={2} />); |
| 88 | ReactNoop.render(<span prop={3} />); |
| 89 | ReactNoop.render(<span prop={4} />); |
| 90 | ReactNoop.render(<span prop={5} />); |
| 91 | |
| 92 | await waitForAll([]); |
| 93 | expect(ReactNoop).toMatchRenderedOutput(<span prop={5} />); |
| 94 | }); |
| 95 | |
| 96 | it('works on deferred roots in the order they were scheduled', async () => { |
| 97 | const {useEffect} = React; |
| 98 | function Text({text}) { |
| 99 | useEffect(() => { |
| 100 | Scheduler.log(text); |
| 101 | }, [text]); |
| 102 | return text; |
| 103 | } |
| 104 | |
| 105 | await act(() => { |
| 106 | ReactNoop.renderToRootWithID(<Text text="a:1" />, 'a'); |
| 107 | ReactNoop.renderToRootWithID(<Text text="b:1" />, 'b'); |
| 108 | ReactNoop.renderToRootWithID(<Text text="c:1" />, 'c'); |
| 109 | }); |
| 110 | assertLog(['a:1', 'b:1', 'c:1']); |
| 111 | |
| 112 | expect(ReactNoop.getChildrenAsJSX('a')).toEqual('a:1'); |
| 113 | expect(ReactNoop.getChildrenAsJSX('b')).toEqual('b:1'); |
| 114 | expect(ReactNoop.getChildrenAsJSX('c')).toEqual('c:1'); |
| 115 | |
| 116 | // Schedule deferred work in the reverse order |
| 117 | await act(async () => { |
| 118 | React.startTransition(() => { |
| 119 | ReactNoop.renderToRootWithID(<Text text="c:2" />, 'c'); |
| 120 | ReactNoop.renderToRootWithID(<Text text="b:2" />, 'b'); |
| 121 | }); |
| 122 | // Ensure it starts in the order it was scheduled |
| 123 | await waitFor(['c:2']); |
| 124 | |
| 125 | expect(ReactNoop.getChildrenAsJSX('a')).toEqual('a:1'); |
| 126 | expect(ReactNoop.getChildrenAsJSX('b')).toEqual('b:1'); |
| 127 | expect(ReactNoop.getChildrenAsJSX('c')).toEqual('c:2'); |
| 128 | // Schedule last bit of work, it will get processed the last |
| 129 | |
| 130 | React.startTransition(() => { |
| 131 | ReactNoop.renderToRootWithID(<Text text="a:2" />, 'a'); |
| 132 | }); |
| 133 | |
| 134 | // Keep performing work in the order it was scheduled |
| 135 | await waitFor(['b:2']); |
| 136 | expect(ReactNoop.getChildrenAsJSX('a')).toEqual('a:1'); |
| 137 | expect(ReactNoop.getChildrenAsJSX('b')).toEqual('b:2'); |
| 138 | expect(ReactNoop.getChildrenAsJSX('c')).toEqual('c:2'); |
| 139 | |
| 140 | await waitFor(['a:2']); |
| 141 | expect(ReactNoop.getChildrenAsJSX('a')).toEqual('a:2'); |
| 142 | expect(ReactNoop.getChildrenAsJSX('b')).toEqual('b:2'); |
| 143 | expect(ReactNoop.getChildrenAsJSX('c')).toEqual('c:2'); |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | it('schedules sync updates when inside componentDidMount/Update', async () => { |
| 148 | let instance; |
| 149 | |
| 150 | class Foo extends React.Component { |
| 151 | state = {tick: 0}; |
| 152 | |
| 153 | componentDidMount() { |
| 154 | Scheduler.log( |
| 155 | 'componentDidMount (before setState): ' + this.state.tick, |
| 156 | ); |
| 157 | this.setState({tick: 1}); |
| 158 | // We're in a batch. Update hasn't flushed yet. |
| 159 | Scheduler.log('componentDidMount (after setState): ' + this.state.tick); |
| 160 | } |
| 161 | |
| 162 | componentDidUpdate() { |
| 163 | Scheduler.log('componentDidUpdate: ' + this.state.tick); |
| 164 | if (this.state.tick === 2) { |
| 165 | Scheduler.log( |
| 166 | 'componentDidUpdate (before setState): ' + this.state.tick, |
| 167 | ); |
| 168 | this.setState({tick: 3}); |
| 169 | Scheduler.log( |
| 170 | 'componentDidUpdate (after setState): ' + this.state.tick, |
| 171 | ); |
| 172 | // We're in a batch. Update hasn't flushed yet. |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | render() { |
| 177 | Scheduler.log('render: ' + this.state.tick); |
| 178 | instance = this; |
| 179 | return <span prop={this.state.tick} />; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | React.startTransition(() => { |
| 184 | ReactNoop.render(<Foo />); |
| 185 | }); |
| 186 | // Render without committing |
| 187 | await waitFor(['render: 0']); |
| 188 | |
| 189 | // Do one more unit of work to commit |
| 190 | expect(ReactNoop.flushNextYield()).toEqual([ |
| 191 | 'componentDidMount (before setState): 0', |
| 192 | 'componentDidMount (after setState): 0', |
| 193 | // If the setState inside componentDidMount were deferred, there would be |
| 194 | // no more ops. Because it has Task priority, we get these ops, too: |
| 195 | 'render: 1', |
| 196 | 'componentDidUpdate: 1', |
| 197 | ]); |
| 198 | |
| 199 | React.startTransition(() => { |
| 200 | instance.setState({tick: 2}); |
| 201 | }); |
| 202 | await waitFor(['render: 2']); |
| 203 | expect(ReactNoop.flushNextYield()).toEqual([ |
| 204 | 'componentDidUpdate: 2', |
| 205 | 'componentDidUpdate (before setState): 2', |
| 206 | 'componentDidUpdate (after setState): 2', |
| 207 | // If the setState inside componentDidUpdate were deferred, there would be |
| 208 | // no more ops. Because it has Task priority, we get these ops, too: |
| 209 | 'render: 3', |
| 210 | 'componentDidUpdate: 3', |
| 211 | ]); |
| 212 | }); |
| 213 | |
| 214 | it('can opt-in to async scheduling inside componentDidMount/Update', async () => { |
| 215 | let instance; |
| 216 | class Foo extends React.Component { |
| 217 | state = {tick: 0}; |
| 218 | |
| 219 | componentDidMount() { |
| 220 | React.startTransition(() => { |
| 221 | Scheduler.log( |
| 222 | 'componentDidMount (before setState): ' + this.state.tick, |
| 223 | ); |
| 224 | this.setState({tick: 1}); |
| 225 | Scheduler.log( |
| 226 | 'componentDidMount (after setState): ' + this.state.tick, |
| 227 | ); |
| 228 | }); |
| 229 | } |
| 230 | |
| 231 | componentDidUpdate() { |
| 232 | React.startTransition(() => { |
| 233 | Scheduler.log('componentDidUpdate: ' + this.state.tick); |
| 234 | if (this.state.tick === 2) { |
| 235 | Scheduler.log( |
| 236 | 'componentDidUpdate (before setState): ' + this.state.tick, |
| 237 | ); |
| 238 | this.setState({tick: 3}); |
| 239 | Scheduler.log( |
| 240 | 'componentDidUpdate (after setState): ' + this.state.tick, |
| 241 | ); |
| 242 | } |
| 243 | }); |
| 244 | } |
| 245 | |
| 246 | render() { |
| 247 | Scheduler.log('render: ' + this.state.tick); |
| 248 | instance = this; |
| 249 | return <span prop={this.state.tick} />; |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | ReactNoop.flushSync(() => { |
| 254 | ReactNoop.render(<Foo />); |
| 255 | }); |
| 256 | // The cDM update should not have flushed yet because it has async priority. |
| 257 | assertLog([ |
| 258 | 'render: 0', |
| 259 | 'componentDidMount (before setState): 0', |
| 260 | 'componentDidMount (after setState): 0', |
| 261 | ]); |
| 262 | expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />); |
| 263 | |
| 264 | // Now flush the cDM update. |
| 265 | await waitForAll(['render: 1', 'componentDidUpdate: 1']); |
| 266 | expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />); |
| 267 | |
| 268 | React.startTransition(() => { |
| 269 | instance.setState({tick: 2}); |
| 270 | }); |
| 271 | |
| 272 | await waitForPaint([ |
| 273 | 'render: 2', |
| 274 | 'componentDidUpdate: 2', |
| 275 | 'componentDidUpdate (before setState): 2', |
| 276 | 'componentDidUpdate (after setState): 2', |
| 277 | ]); |
| 278 | expect(ReactNoop).toMatchRenderedOutput(<span prop={2} />); |
| 279 | |
| 280 | // Now flush the cDU update. |
| 281 | await waitForAll(['render: 3', 'componentDidUpdate: 3']); |
| 282 | expect(ReactNoop).toMatchRenderedOutput(<span prop={3} />); |
| 283 | }); |
| 284 | |
| 285 | it('performs Task work even after time runs out', async () => { |
| 286 | class Foo extends React.Component { |
| 287 | state = {step: 1}; |
| 288 | componentDidMount() { |
| 289 | this.setState({step: 2}, () => { |
| 290 | this.setState({step: 3}, () => { |
| 291 | this.setState({step: 4}, () => { |
| 292 | this.setState({step: 5}); |
| 293 | }); |
| 294 | }); |
| 295 | }); |
| 296 | } |
| 297 | render() { |
| 298 | Scheduler.log('Foo'); |
| 299 | return <span prop={this.state.step} />; |
| 300 | } |
| 301 | } |
| 302 | React.startTransition(() => { |
| 303 | ReactNoop.render(<Foo />); |
| 304 | }); |
| 305 | |
| 306 | // This should be just enough to complete all the work, but not enough to |
| 307 | // commit it. |
| 308 | await waitFor(['Foo']); |
| 309 | expect(ReactNoop).toMatchRenderedOutput(null); |
| 310 | |
| 311 | // Do one more unit of work. |
| 312 | ReactNoop.flushNextYield(); |
| 313 | // The updates should all be flushed with Task priority |
| 314 | expect(ReactNoop).toMatchRenderedOutput(<span prop={5} />); |
| 315 | }); |
| 316 | }); |