| 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 reactcore |
| 8 | * @jest-environment node |
| 9 | */ |
| 10 | |
| 11 | 'use strict'; |
| 12 | |
| 13 | let JSDOM; |
| 14 | let React; |
| 15 | let ReactDOMClient; |
| 16 | let act; |
| 17 | let document; |
| 18 | let Fragment; |
| 19 | let Node; |
| 20 | |
| 21 | describe('FragmentRefs', () => { |
| 22 | beforeEach(() => { |
| 23 | jest.resetModules(); |
| 24 | JSDOM = require('jsdom'); |
| 25 | React = require('react'); |
| 26 | Fragment = React.Fragment; |
| 27 | ReactDOMClient = require('react-dom/client'); |
| 28 | act = require('internal-test-utils').act; |
| 29 | |
| 30 | const jsdom = new JSDOM.JSDOM(''); |
| 31 | document = jsdom.window.document; |
| 32 | Node = jsdom.window.Node; |
| 33 | global.window = jsdom.window; |
| 34 | global.document = global.window.document; |
| 35 | global.navigator = global.window.navigator; |
| 36 | global.Event = global.window.Event; |
| 37 | global.MouseEvent = global.window.MouseEvent; |
| 38 | global.Node = Node; |
| 39 | }); |
| 40 | |
| 41 | describe('focus methods', () => { |
| 42 | describe('blur()', () => { |
| 43 | // @gate enableFragmentRefs |
| 44 | it('throws when the nearest host parent is a Document container', async () => { |
| 45 | const fragmentRef = React.createRef(); |
| 46 | const root = ReactDOMClient.createRoot(document); |
| 47 | |
| 48 | await act(() => { |
| 49 | root.render( |
| 50 | <Fragment ref={fragmentRef}> |
| 51 | <html> |
| 52 | <body> |
| 53 | <a id="child-a" href="/"> |
| 54 | A |
| 55 | </a> |
| 56 | </body> |
| 57 | </html> |
| 58 | </Fragment>, |
| 59 | ); |
| 60 | }); |
| 61 | |
| 62 | await act(() => { |
| 63 | // focus() would stop at <body>, which is a child of the fragment |
| 64 | // and usually already the activeElement. |
| 65 | document.getElementById('child-a').focus(); |
| 66 | }); |
| 67 | expect(document.activeElement.id).toEqual('child-a'); |
| 68 | |
| 69 | await act(() => { |
| 70 | fragmentRef.current.blur(); |
| 71 | }); |
| 72 | expect(document.activeElement).toEqual(document.body); |
| 73 | }); |
| 74 | }); |
| 75 | }); |
| 76 | |
| 77 | describe('events', () => { |
| 78 | describe('dispatchEvent()', () => { |
| 79 | // @gate enableFragmentRefs |
| 80 | it('fires events when the fragment is a child of a HostSingleton in a document root', async () => { |
| 81 | const fragmentRef = React.createRef(); |
| 82 | const bodyRef = React.createRef(); |
| 83 | const root = ReactDOMClient.createRoot(document); |
| 84 | |
| 85 | await act(() => { |
| 86 | root.render( |
| 87 | <html> |
| 88 | <body ref={bodyRef}> |
| 89 | <Fragment ref={fragmentRef} /> |
| 90 | </body> |
| 91 | </html>, |
| 92 | ); |
| 93 | }); |
| 94 | |
| 95 | const fragmentListener = jest.fn(); |
| 96 | fragmentRef.current.addEventListener('custom', fragmentListener); |
| 97 | const bodyListener = jest.fn(); |
| 98 | bodyRef.current.addEventListener('custom', bodyListener); |
| 99 | |
| 100 | // The <body> is the fragment's host parent, so the |
| 101 | // temporary event target is appended there. |
| 102 | fragmentRef.current.dispatchEvent(new Event('custom', {bubbles: true})); |
| 103 | |
| 104 | expect(fragmentListener).toHaveBeenCalledTimes(1); |
| 105 | expect(bodyListener).toHaveBeenCalledTimes(1); |
| 106 | }); |
| 107 | |
| 108 | // @gate enableFragmentRefs |
| 109 | it('dispatches to its own listeners when the container is a Document', async () => { |
| 110 | const fragmentRef = React.createRef(); |
| 111 | const root = ReactDOMClient.createRoot(document); |
| 112 | const logs = []; |
| 113 | |
| 114 | await act(() => { |
| 115 | root.render( |
| 116 | <> |
| 117 | <Fragment ref={fragmentRef} /> |
| 118 | <html> |
| 119 | <body> |
| 120 | <div id="child" /> |
| 121 | </body> |
| 122 | </html> |
| 123 | </>, |
| 124 | ); |
| 125 | }); |
| 126 | |
| 127 | fragmentRef.current.addEventListener('click', () => { |
| 128 | logs.push('fragment'); |
| 129 | }); |
| 130 | document.addEventListener('click', () => { |
| 131 | logs.push('document'); |
| 132 | }); |
| 133 | |
| 134 | const isCancelable = !fragmentRef.current.dispatchEvent( |
| 135 | new MouseEvent('click', {bubbles: true}), |
| 136 | ); |
| 137 | |
| 138 | expect(logs).toEqual(['fragment', 'document']); |
| 139 | expect(isCancelable).toBe(false); |
| 140 | }); |
| 141 | |
| 142 | // @gate enableFragmentRefs |
| 143 | it('does not propagate through its own children when wrapping documentElement', async () => { |
| 144 | const fragmentRef = React.createRef(); |
| 145 | const root = ReactDOMClient.createRoot(document); |
| 146 | const logs = []; |
| 147 | |
| 148 | await act(() => { |
| 149 | root.render( |
| 150 | <Fragment ref={fragmentRef}> |
| 151 | <html> |
| 152 | <body> |
| 153 | <div id="child" /> |
| 154 | </body> |
| 155 | </html> |
| 156 | </Fragment>, |
| 157 | ); |
| 158 | }); |
| 159 | |
| 160 | // This also registers the listener on the <html> child. Because the |
| 161 | // fragment's position is a sibling of <html>, the event must not |
| 162 | // propagate through it and fire the listener a second time. |
| 163 | fragmentRef.current.addEventListener('click', () => { |
| 164 | logs.push('fragment'); |
| 165 | }); |
| 166 | document.addEventListener('click', () => { |
| 167 | logs.push('document'); |
| 168 | }); |
| 169 | |
| 170 | fragmentRef.current.dispatchEvent( |
| 171 | new MouseEvent('click', {bubbles: true}), |
| 172 | ); |
| 173 | |
| 174 | expect(logs).toEqual(['fragment', 'document']); |
| 175 | }); |
| 176 | |
| 177 | // @gate enableFragmentRefs |
| 178 | it('dispatches non-bubbling events when the container is a Document', async () => { |
| 179 | const fragmentRef = React.createRef(); |
| 180 | const root = ReactDOMClient.createRoot(document); |
| 181 | const logs = []; |
| 182 | |
| 183 | await act(() => { |
| 184 | root.render( |
| 185 | <> |
| 186 | <Fragment ref={fragmentRef} /> |
| 187 | <html> |
| 188 | <body> |
| 189 | <div id="child" /> |
| 190 | </body> |
| 191 | </html> |
| 192 | </>, |
| 193 | ); |
| 194 | }); |
| 195 | |
| 196 | document.addEventListener('click', () => { |
| 197 | logs.push('document'); |
| 198 | }); |
| 199 | |
| 200 | const isCancelable = !fragmentRef.current.dispatchEvent( |
| 201 | new MouseEvent('click', {bubbles: false}), |
| 202 | ); |
| 203 | |
| 204 | expect(logs).toEqual([]); |
| 205 | expect(isCancelable).toBe(false); |
| 206 | }); |
| 207 | }); |
| 208 | |
| 209 | describe('addEventListener()', () => { |
| 210 | // @gate enableFragmentRefs |
| 211 | it('attaches listeners to the host children inside singletons', async () => { |
| 212 | const fragmentRef = React.createRef(); |
| 213 | const childRef = React.createRef(); |
| 214 | const root = ReactDOMClient.createRoot(document); |
| 215 | |
| 216 | await act(() => { |
| 217 | root.render( |
| 218 | <Fragment ref={fragmentRef}> |
| 219 | <html> |
| 220 | <body> |
| 221 | <div ref={childRef} id="child" /> |
| 222 | </body> |
| 223 | </html> |
| 224 | </Fragment>, |
| 225 | ); |
| 226 | }); |
| 227 | |
| 228 | const currentTargets = []; |
| 229 | fragmentRef.current.addEventListener('click', event => { |
| 230 | currentTargets.push(event.currentTarget); |
| 231 | }); |
| 232 | |
| 233 | childRef.current.dispatchEvent(new Event('click', {bubbles: true})); |
| 234 | |
| 235 | // The <html> singleton is the fragment's child, so the listener is |
| 236 | // attached there and receives the bubbling event. |
| 237 | expect(currentTargets).toEqual([document.documentElement]); |
| 238 | }); |
| 239 | |
| 240 | // @gate enableFragmentRefs |
| 241 | it('attaches listeners to a singleton mounted into the fragment, but not to its content', async () => { |
| 242 | const fragmentRef = React.createRef(); |
| 243 | const childRef = React.createRef(); |
| 244 | const root = ReactDOMClient.createRoot(document); |
| 245 | |
| 246 | function Test({showShell}) { |
| 247 | return ( |
| 248 | <Fragment ref={fragmentRef}> |
| 249 | {showShell && ( |
| 250 | <html> |
| 251 | <body> |
| 252 | <div ref={childRef} id="child" /> |
| 253 | </body> |
| 254 | </html> |
| 255 | )} |
| 256 | </Fragment> |
| 257 | ); |
| 258 | } |
| 259 | |
| 260 | await act(() => { |
| 261 | root.render(<Test showShell={false} />); |
| 262 | }); |
| 263 | |
| 264 | const currentTargets = []; |
| 265 | fragmentRef.current.addEventListener('click', event => { |
| 266 | currentTargets.push(event.currentTarget); |
| 267 | }); |
| 268 | |
| 269 | await act(() => { |
| 270 | root.render(<Test showShell={true} />); |
| 271 | }); |
| 272 | |
| 273 | childRef.current.dispatchEvent(new Event('click', {bubbles: true})); |
| 274 | |
| 275 | // The placed <html> singleton receives the fragment's listener as a |
| 276 | // new child. Its content is not attributed to the fragment, so the |
| 277 | // event only fires once when it bubbles to <html>. |
| 278 | expect(currentTargets).toEqual([document.documentElement]); |
| 279 | }); |
| 280 | |
| 281 | // @gate enableFragmentRefs |
| 282 | it('attributes new children inside a singleton to fragments below it, not above it', async () => { |
| 283 | const outerFragmentRef = React.createRef(); |
| 284 | const innerFragmentRef = React.createRef(); |
| 285 | const lateChildRef = React.createRef(); |
| 286 | const root = ReactDOMClient.createRoot(document); |
| 287 | |
| 288 | function Test({showLateChild}) { |
| 289 | return ( |
| 290 | <Fragment ref={outerFragmentRef}> |
| 291 | <html> |
| 292 | <body> |
| 293 | <Fragment ref={innerFragmentRef}> |
| 294 | <div id="child" /> |
| 295 | {showLateChild && <span ref={lateChildRef} id="late" />} |
| 296 | </Fragment> |
| 297 | </body> |
| 298 | </html> |
| 299 | </Fragment> |
| 300 | ); |
| 301 | } |
| 302 | |
| 303 | await act(() => { |
| 304 | root.render(<Test showLateChild={false} />); |
| 305 | }); |
| 306 | |
| 307 | const outerCurrentTargets = []; |
| 308 | outerFragmentRef.current.addEventListener('click', event => { |
| 309 | outerCurrentTargets.push(event.currentTarget); |
| 310 | }); |
| 311 | const innerCurrentTargets = []; |
| 312 | innerFragmentRef.current.addEventListener('click', event => { |
| 313 | innerCurrentTargets.push(event.currentTarget); |
| 314 | }); |
| 315 | |
| 316 | await act(() => { |
| 317 | root.render(<Test showLateChild={true} />); |
| 318 | }); |
| 319 | |
| 320 | lateChildRef.current.dispatchEvent(new Event('click', {bubbles: true})); |
| 321 | |
| 322 | // The inner fragment owns the new child directly and attaches its |
| 323 | // listener on insertion. The outer fragment's child is the <html> |
| 324 | // singleton, so the new child inside <body> is not attributed to it |
| 325 | // and its listener only fires once via bubbling. |
| 326 | expect(innerCurrentTargets).toEqual([lateChildRef.current]); |
| 327 | expect(outerCurrentTargets).toEqual([document.documentElement]); |
| 328 | }); |
| 329 | }); |
| 330 | }); |
| 331 | |
| 332 | describe('getClientRects()', () => { |
| 333 | // @gate enableFragmentRefs |
| 334 | it('measures the host children inside singletons', async () => { |
| 335 | const fragmentRef = React.createRef(); |
| 336 | const childRef = React.createRef(); |
| 337 | const root = ReactDOMClient.createRoot(document); |
| 338 | |
| 339 | await act(() => { |
| 340 | root.render( |
| 341 | <Fragment ref={fragmentRef}> |
| 342 | <html> |
| 343 | <body> |
| 344 | <div ref={childRef} id="child" /> |
| 345 | </body> |
| 346 | </html> |
| 347 | </Fragment>, |
| 348 | ); |
| 349 | }); |
| 350 | |
| 351 | childRef.current.getClientRects = jest.fn(() => ['child-rect']); |
| 352 | document.documentElement.getClientRects = jest.fn(() => ['html-rect']); |
| 353 | |
| 354 | // The <html> singleton is the fragment's child, so it is measured |
| 355 | // instead of the elements inside it |
| 356 | expect(fragmentRef.current.getClientRects()).toEqual(['html-rect']); |
| 357 | }); |
| 358 | }); |
| 359 | |
| 360 | describe('compareDocumentPosition', () => { |
| 361 | function expectPosition(position, spec) { |
| 362 | const positionResult = { |
| 363 | following: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, |
| 364 | preceding: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0, |
| 365 | contains: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0, |
| 366 | containedBy: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0, |
| 367 | disconnected: (position & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0, |
| 368 | implementationSpecific: |
| 369 | (position & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) !== 0, |
| 370 | }; |
| 371 | expect(positionResult).toEqual(spec); |
| 372 | } |
| 373 | |
| 374 | // @gate enableFragmentRefs |
| 375 | it('treats documentElement as containing the fragment', async () => { |
| 376 | const fragmentRef = React.createRef(); |
| 377 | const container = document.createElement('div'); |
| 378 | document.body.appendChild(container); |
| 379 | const root = ReactDOMClient.createRoot(container); |
| 380 | |
| 381 | await act(() => { |
| 382 | root.render( |
| 383 | <Fragment ref={fragmentRef}> |
| 384 | <div id="child" /> |
| 385 | </Fragment>, |
| 386 | ); |
| 387 | }); |
| 388 | |
| 389 | expectPosition( |
| 390 | fragmentRef.current.compareDocumentPosition(document.documentElement), |
| 391 | { |
| 392 | preceding: true, |
| 393 | following: false, |
| 394 | contains: true, |
| 395 | containedBy: false, |
| 396 | disconnected: false, |
| 397 | implementationSpecific: false, |
| 398 | }, |
| 399 | ); |
| 400 | }); |
| 401 | }); |
| 402 | }); |