Repository: facebook/react
Pull request: fix: restore selection should consider the window of the container

<!--
  Thanks for submitting a pull request!
  We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory.

  Before submitting a pull request, please make sure the following is done:

  1. Fork [the repository](https://github.com/facebook/react) and create your branch from `main`.
  2. Run `yarn` in the repository root.
  3. If you've fixed a bug or added code that should be tested, add tests!
  4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch TestName` is helpful in development.
  5. Run `yarn test --prod` to test in the production environment. It supports the same options as `yarn test`.
  6. If you need a debugger, run `yarn test --debug --watch TestName`, open `chrome://inspect`, and press "Inspect".
  7. Format your code with [prettier](https://github.com/prettier/prettier) (`yarn prettier`).
  8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

  Learn more about contributing: https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
 Explain the **motivation** for making this change. What existing problem does the pull request solve?
-->

Fixes #30864 

Before this PR the active elemen was always taken from the global `window`. This is incorrect if the renderer is in one window rendering into a container element in another window. The changes in this PR adds another code branch to use a `defaultView` of the container element if it exists so that `restoreSelection` after a commit will actually restore to the correct window.

## How did you test this change?

I patched these changes to the repro repo in the linked issue #39864 https://github.com/ling1726/react-child-window-focus-repro/blob/master/patches/react-dom%2B18.3.1.patch.

I followed the same repro steps in the linked issue and and could not repro the reported problem. Attaching screen recordings below:

Before
![focus repro](https://github.com/user-attachments/assets/81c4b4f9-08b5-4356-8251-49b909771f3f)

After
![after](https://github.com/user-attachments/assets/84883032-5558-4650-9b9a-bd4d5fd9cb13)

<!--
  Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes the user interface.
  How exactly did you verify that your PR solves the issue you wanted to solve?
  If you leave this empty, your PR will very likely be closed.
-->


Changed files:
filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -325,7 +325,7 @@ export function getPublicInstance(instance: Instance): Instance {
 
 export function prepareForCommit(containerInfo: Container): Object | null {
   eventsEnabled = ReactBrowserEventEmitterIsEnabled();
-  selectionInformation = getSelectionInformation();
+  selectionInformation = getSelectionInformation(containerInfo);
   let activeInstance = null;
   if (enableCreateEventHandleAPI) {
     const focusedElem = selectionInformation.focusedElem;
@@ -357,7 +357,7 @@ export function afterActiveInstanceBlur(): void {
 }
 
 export function resetAfterCommit(containerInfo: Container): void {
-  restoreSelection(selectionInformation);
+  restoreSelection(selectionInformation, containerInfo);
   ReactBrowserEventEmitterSetEnabled(eventsEnabled);
   eventsEnabled = null;
   selectionInformation = null;

filename: packages/react-dom-bindings/src/client/ReactInputSelection.js
patch: @@ -56,9 +56,9 @@ function isSameOriginFrame(iframe) {
   }
 }
 
-function getActiveElementDeep() {
-  let win = window;
-  let element = getActiveElement();
+function getActiveElementDeep(containerInfo) {
+  let win = containerInfo?.ownerDocument?.defaultView ?? window;
+  let element = getActiveElement(win.document);
   while (element instanceof win.HTMLIFrameElement) {
     if (isSameOriginFrame(element)) {
       win = element.contentWindow;
@@ -97,8 +97,8 @@ export function hasSelectionCapabilities(elem) {
   );
 }
 
-export function getSelectionInformation() {
-  const focusedElem = getActiveElementDeep();
+export function getSelectionInformation(containerInfo) {
+  const focusedElem = getActiveElementDeep(containerInfo);
   return {
     focusedElem: focusedElem,
     selectionRange: hasSelectionCapabilities(focusedElem)
@@ -112,8 +112,8 @@ export function getSelectionInformation() {
  * restore it. This is useful when performing operations that could remove dom
  * nodes and place them back in, resulting in focus being lost.
  */
-export function restoreSelection(priorSelectionInformation) {
-  const curFocusedElem = getActiveElementDeep();
+export function restoreSelection(priorSelectionInformation, containerInfo) {
+  const curFocusedElem = getActiveElementDeep(containerInfo);
   const priorFocusedElem = priorSelectionInformation.focusedElem;
   const priorSelectionRange = priorSelectionInformation.selectionRange;
   if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) {

filename: packages/react-dom/src/__tests__/ReactDOMFiber-test.js
patch: @@ -19,12 +19,23 @@ let act;
 let assertConsoleErrorDev;
 let assertLog;
 let root;
+let JSDOM;
 
 describe('ReactDOMFiber', () => {
   let container;
 
   beforeEach(() => {
     jest.resetModules();
+
+    // JSDOM needs to be setup with a TextEncoder and TextDecoder when used standalone
+    // https://github.com/jsdom/jsdom/issues/2524
+    (() => {
+      const {TextEncoder, TextDecoder} = require('util');
+      global.TextEncoder = TextEncoder;
+      global.TextDecoder = TextDecoder;
+      JSDOM = require('jsdom').JSDOM;
+    })();
+
     React = require('react');
     ReactDOM = require('react-dom');
     PropTypes = require('prop-types');
@@ -1272,4 +1283,48 @@ describe('ReactDOMFiber', () => {
     });
     expect(didCallOnChange).toBe(true);
   });
+
+  it('should restore selection in the correct window', async () => {
+    // creating new JSDOM instance to get a second window as window.open is not implemented
+    // https://github.com/jsdom/jsdom/blob/c53efc81e75f38a0558fbf3ed75d30b78b4c4898/lib/jsdom/browser/Window.js#L987
+    const {window: newWindow} = new JSDOM('');
+    // creating a new container since the default cleanup expects the existing container to be in the document
+    const newContainer = newWindow.document.createElement('div');
+    newWindow.document.body.appendChild(newContainer);
+    root = ReactDOMClient.createRoot(newContainer);
+
+    const Test = () => {
+      const [reverse, setReverse] = React.useState(false);
+      const [items] = React.useState(() => ['a', 'b', 'c']);
+      const onClick = () => {
+        setReverse(true);
+      };
+
+      // shuffle the items so that the react commit needs to restore focus
+      // to the correct element after commit
+      const itemsToRender = reverse ? items.reverse() : items;
+
+      return (
+        <div>
+          {itemsToRender.map(item => (
+            <button onClick={onClick} key={item} id={item}>
+              {item}
+            </button>
+          ))}
+        </div>
+      );
+    };
+
+    await act(() => {
+      root.render(<Test />);
+    });
+
+    newWindow.document.getElementById('a').focus();
+    await act(() => {
+      newWindow.document.getElementById('a').click();
+    });
+
+    expect(newWindow.document.activeElement).not.toBe(newWindow.document.body);
+    expect(newWindow.document.activeElement.innerHTML).toBe('a');
+  });
 });