| 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 | */ |
| 9 | |
| 10 | 'use strict'; |
| 11 | |
| 12 | let React; |
| 13 | let ReactDOM; |
| 14 | let findDOMNode; |
| 15 | let ReactDOMClient; |
| 16 | let TogglingComponent; |
| 17 | let act; |
| 18 | let Scheduler; |
| 19 | let assertLog; |
| 20 | let assertConsoleErrorDev; |
| 21 | |
| 22 | let container; |
| 23 | |
| 24 | describe('ReactEmptyComponent', () => { |
| 25 | beforeEach(() => { |
| 26 | jest.resetModules(); |
| 27 | |
| 28 | React = require('react'); |
| 29 | ReactDOM = require('react-dom'); |
| 30 | ReactDOMClient = require('react-dom/client'); |
| 31 | findDOMNode = |
| 32 | ReactDOM.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE |
| 33 | .findDOMNode; |
| 34 | Scheduler = require('scheduler'); |
| 35 | const InternalTestUtils = require('internal-test-utils'); |
| 36 | act = InternalTestUtils.act; |
| 37 | assertLog = InternalTestUtils.assertLog; |
| 38 | assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; |
| 39 | |
| 40 | container = document.createElement('div'); |
| 41 | |
| 42 | TogglingComponent = class extends React.Component { |
| 43 | state = {component: this.props.firstComponent}; |
| 44 | |
| 45 | componentDidMount() { |
| 46 | Scheduler.log('mount ' + findDOMNode(this)?.nodeName); |
| 47 | this.setState({component: this.props.secondComponent}); |
| 48 | } |
| 49 | |
| 50 | componentDidUpdate() { |
| 51 | Scheduler.log('update ' + findDOMNode(this)?.nodeName); |
| 52 | } |
| 53 | |
| 54 | render() { |
| 55 | const Component = this.state.component; |
| 56 | return Component ? <Component /> : null; |
| 57 | } |
| 58 | }; |
| 59 | }); |
| 60 | |
| 61 | describe.each([null, undefined])('when %s', nullORUndefined => { |
| 62 | it('should not throw when rendering', () => { |
| 63 | function EmptyComponent() { |
| 64 | return nullORUndefined; |
| 65 | } |
| 66 | |
| 67 | const root = ReactDOMClient.createRoot(container); |
| 68 | |
| 69 | expect(() => { |
| 70 | ReactDOM.flushSync(() => { |
| 71 | root.render(<EmptyComponent />); |
| 72 | }); |
| 73 | }).not.toThrow(); |
| 74 | }); |
| 75 | |
| 76 | it('should not produce child DOM nodes for nullish and false', async () => { |
| 77 | function Component1() { |
| 78 | return nullORUndefined; |
| 79 | } |
| 80 | |
| 81 | function Component2() { |
| 82 | return false; |
| 83 | } |
| 84 | |
| 85 | const container1 = document.createElement('div'); |
| 86 | const root1 = ReactDOMClient.createRoot(container1); |
| 87 | await act(() => { |
| 88 | root1.render(<Component1 />); |
| 89 | }); |
| 90 | expect(container1.children.length).toBe(0); |
| 91 | |
| 92 | const container2 = document.createElement('div'); |
| 93 | const root2 = ReactDOMClient.createRoot(container2); |
| 94 | await act(() => { |
| 95 | root2.render(<Component2 />); |
| 96 | }); |
| 97 | expect(container2.children.length).toBe(0); |
| 98 | }); |
| 99 | |
| 100 | it('should be able to switch between rendering nullish and a normal tag', async () => { |
| 101 | const instance1 = ( |
| 102 | <TogglingComponent |
| 103 | firstComponent={nullORUndefined} |
| 104 | secondComponent={'div'} |
| 105 | /> |
| 106 | ); |
| 107 | const instance2 = ( |
| 108 | <TogglingComponent |
| 109 | firstComponent={'div'} |
| 110 | secondComponent={nullORUndefined} |
| 111 | /> |
| 112 | ); |
| 113 | |
| 114 | const container2 = document.createElement('div'); |
| 115 | const root1 = ReactDOMClient.createRoot(container); |
| 116 | await act(() => { |
| 117 | root1.render(instance1); |
| 118 | }); |
| 119 | |
| 120 | assertLog(['mount undefined', 'update DIV']); |
| 121 | |
| 122 | const root2 = ReactDOMClient.createRoot(container2); |
| 123 | await act(() => { |
| 124 | root2.render(instance2); |
| 125 | }); |
| 126 | |
| 127 | assertLog(['mount DIV', 'update undefined']); |
| 128 | }); |
| 129 | |
| 130 | it('should be able to switch in a list of children', async () => { |
| 131 | const instance1 = ( |
| 132 | <TogglingComponent |
| 133 | firstComponent={nullORUndefined} |
| 134 | secondComponent={'div'} |
| 135 | /> |
| 136 | ); |
| 137 | |
| 138 | const root = ReactDOMClient.createRoot(container); |
| 139 | await act(() => { |
| 140 | root.render( |
| 141 | <div> |
| 142 | {instance1} |
| 143 | {instance1} |
| 144 | {instance1} |
| 145 | </div>, |
| 146 | ); |
| 147 | }); |
| 148 | |
| 149 | assertLog([ |
| 150 | 'mount undefined', |
| 151 | 'mount undefined', |
| 152 | 'mount undefined', |
| 153 | 'update DIV', |
| 154 | 'update DIV', |
| 155 | 'update DIV', |
| 156 | ]); |
| 157 | }); |
| 158 | |
| 159 | it('should distinguish between a script placeholder and an actual script tag', () => { |
| 160 | const instance1 = ( |
| 161 | <TogglingComponent |
| 162 | firstComponent={nullORUndefined} |
| 163 | secondComponent={'script'} |
| 164 | /> |
| 165 | ); |
| 166 | const instance2 = ( |
| 167 | <TogglingComponent |
| 168 | firstComponent={'script'} |
| 169 | secondComponent={nullORUndefined} |
| 170 | /> |
| 171 | ); |
| 172 | |
| 173 | const root1 = ReactDOMClient.createRoot(container); |
| 174 | expect(() => { |
| 175 | ReactDOM.flushSync(() => { |
| 176 | root1.render(instance1); |
| 177 | }); |
| 178 | }).not.toThrow(); |
| 179 | |
| 180 | expect(container.innerHTML).toBe('<script></script>'); |
| 181 | if (gate('enableTrustedTypesIntegration')) { |
| 182 | assertConsoleErrorDev([ |
| 183 | 'Encountered a script tag while rendering React component. ' + |
| 184 | 'Scripts inside React components are never executed when rendering on the client. ' + |
| 185 | 'Consider using template tag instead (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).\n' + |
| 186 | ' in script (at **)\n' + |
| 187 | ' in TogglingComponent (at **)', |
| 188 | ]); |
| 189 | } |
| 190 | |
| 191 | const container2 = document.createElement('div'); |
| 192 | const root2 = ReactDOMClient.createRoot(container2); |
| 193 | expect(() => { |
| 194 | ReactDOM.flushSync(() => { |
| 195 | root2.render(instance2); |
| 196 | }); |
| 197 | }).not.toThrow(); |
| 198 | |
| 199 | assertLog([ |
| 200 | 'mount undefined', |
| 201 | 'update SCRIPT', |
| 202 | 'mount SCRIPT', |
| 203 | 'update undefined', |
| 204 | ]); |
| 205 | expect(container2.innerHTML).toBe(''); |
| 206 | }); |
| 207 | |
| 208 | it( |
| 209 | 'should have findDOMNode return null when multiple layers of composite ' + |
| 210 | 'components render to the same nullish placeholder', |
| 211 | () => { |
| 212 | function GrandChild() { |
| 213 | return nullORUndefined; |
| 214 | } |
| 215 | |
| 216 | function Child() { |
| 217 | return <GrandChild />; |
| 218 | } |
| 219 | |
| 220 | const instance1 = ( |
| 221 | <TogglingComponent firstComponent={'div'} secondComponent={Child} /> |
| 222 | ); |
| 223 | const instance2 = ( |
| 224 | <TogglingComponent firstComponent={Child} secondComponent={'div'} /> |
| 225 | ); |
| 226 | |
| 227 | const root1 = ReactDOMClient.createRoot(container); |
| 228 | expect(() => { |
| 229 | ReactDOM.flushSync(() => { |
| 230 | root1.render(instance1); |
| 231 | }); |
| 232 | }).not.toThrow(); |
| 233 | |
| 234 | const container2 = document.createElement('div'); |
| 235 | const root2 = ReactDOMClient.createRoot(container2); |
| 236 | expect(() => { |
| 237 | ReactDOM.flushSync(() => { |
| 238 | root2.render(instance2); |
| 239 | }); |
| 240 | }).not.toThrow(); |
| 241 | |
| 242 | assertLog([ |
| 243 | 'mount DIV', |
| 244 | 'update undefined', |
| 245 | 'mount undefined', |
| 246 | 'update DIV', |
| 247 | ]); |
| 248 | }, |
| 249 | ); |
| 250 | |
| 251 | it('works when switching components', async () => { |
| 252 | let innerRef; |
| 253 | |
| 254 | class Inner extends React.Component { |
| 255 | render() { |
| 256 | return <span />; |
| 257 | } |
| 258 | |
| 259 | componentDidMount() { |
| 260 | // Make sure the DOM node resolves properly even if we're replacing a |
| 261 | // `null` component |
| 262 | expect(findDOMNode(this)).not.toBe(null); |
| 263 | } |
| 264 | |
| 265 | componentWillUnmount() { |
| 266 | // Even though we're getting replaced by `null`, we haven't been |
| 267 | // replaced yet! |
| 268 | expect(findDOMNode(this)).not.toBe(null); |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | function Wrapper({showInner}) { |
| 273 | innerRef = React.createRef(null); |
| 274 | return showInner ? <Inner ref={innerRef} /> : nullORUndefined; |
| 275 | } |
| 276 | |
| 277 | const el = document.createElement('div'); |
| 278 | |
| 279 | // Render the <Inner /> component... |
| 280 | const root = ReactDOMClient.createRoot(el); |
| 281 | await act(() => { |
| 282 | root.render(<Wrapper showInner={true} />); |
| 283 | }); |
| 284 | expect(innerRef.current).not.toBe(null); |
| 285 | |
| 286 | // Switch to null... |
| 287 | await act(() => { |
| 288 | root.render(<Wrapper showInner={false} />); |
| 289 | }); |
| 290 | expect(innerRef.current).toBe(null); |
| 291 | |
| 292 | // ...then switch back. |
| 293 | await act(() => { |
| 294 | root.render(<Wrapper showInner={true} />); |
| 295 | }); |
| 296 | expect(innerRef.current).not.toBe(null); |
| 297 | |
| 298 | expect.assertions(6); |
| 299 | }); |
| 300 | |
| 301 | it('can render nullish at the top level', async () => { |
| 302 | const div = document.createElement('div'); |
| 303 | const root = ReactDOMClient.createRoot(div); |
| 304 | |
| 305 | await act(() => { |
| 306 | root.render(nullORUndefined); |
| 307 | }); |
| 308 | expect(div.innerHTML).toBe(''); |
| 309 | }); |
| 310 | |
| 311 | it('does not break when updating during mount', () => { |
| 312 | class Child extends React.Component { |
| 313 | componentDidMount() { |
| 314 | if (this.props.onMount) { |
| 315 | this.props.onMount(); |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | render() { |
| 320 | if (!this.props.visible) { |
| 321 | return nullORUndefined; |
| 322 | } |
| 323 | |
| 324 | return <div>hello world</div>; |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | class Parent extends React.Component { |
| 329 | update = () => { |
| 330 | this.forceUpdate(); |
| 331 | }; |
| 332 | |
| 333 | render() { |
| 334 | return ( |
| 335 | <div> |
| 336 | <Child key="1" visible={false} /> |
| 337 | <Child key="0" visible={true} onMount={this.update} /> |
| 338 | <Child key="2" visible={false} /> |
| 339 | </div> |
| 340 | ); |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | const root = ReactDOMClient.createRoot(container); |
| 345 | expect(() => { |
| 346 | ReactDOM.flushSync(() => { |
| 347 | root.render(<Parent />); |
| 348 | }); |
| 349 | }).not.toThrow(); |
| 350 | }); |
| 351 | |
| 352 | it('preserves the dom node during updates', async () => { |
| 353 | function Empty() { |
| 354 | return nullORUndefined; |
| 355 | } |
| 356 | |
| 357 | const root = ReactDOMClient.createRoot(container); |
| 358 | await act(() => { |
| 359 | root.render(<Empty />); |
| 360 | }); |
| 361 | const noscript1 = container.firstChild; |
| 362 | expect(noscript1).toBe(null); |
| 363 | |
| 364 | // This update shouldn't create a DOM node |
| 365 | await act(() => { |
| 366 | root.render(<Empty />); |
| 367 | }); |
| 368 | const noscript2 = container.firstChild; |
| 369 | expect(noscript2).toBe(null); |
| 370 | }); |
| 371 | |
| 372 | it('should not warn about React.forwardRef that returns nullish', () => { |
| 373 | const Empty = () => { |
| 374 | return nullORUndefined; |
| 375 | }; |
| 376 | const EmptyForwardRef = React.forwardRef(Empty); |
| 377 | |
| 378 | const root = ReactDOMClient.createRoot(container); |
| 379 | expect(() => { |
| 380 | ReactDOM.flushSync(() => { |
| 381 | root.render(<EmptyForwardRef />); |
| 382 | }); |
| 383 | }).not.toThrow(); |
| 384 | }); |
| 385 | |
| 386 | it('should not warn about React.memo that returns nullish', () => { |
| 387 | const Empty = () => { |
| 388 | return nullORUndefined; |
| 389 | }; |
| 390 | const EmptyMemo = React.memo(Empty); |
| 391 | |
| 392 | const root = ReactDOMClient.createRoot(container); |
| 393 | expect(() => { |
| 394 | ReactDOM.flushSync(() => { |
| 395 | root.render(<EmptyMemo />); |
| 396 | }); |
| 397 | }).not.toThrow(); |
| 398 | }); |
| 399 | }); |
| 400 | }); |