@samitouri / QOS-React-2 / commits / 305feb9058

[DOM] Fix Fragment dispatchEvent when the container is a Document (#37165)

dispatchEvent appends a temporary Text node to the fragment's nearest host parent, but a Document can't contain Text, so createRoot(document) threw HierarchyRequestError whenever the fragment had a listener or the event didn't bubble. Use a Comment node for Document containers: it is a legal document child and sits at the fragment's own position, unlike documentElement, which would put the target inside the fragment and fire its listeners twice.

Jack Pope committed Aug 11, 2026 at 18:32 UTC 305feb9058f38363e25c61d69985b9393b16cc2a
2 files changed +109 -1
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+8 -1
@@ -3156,7 +3156,14 @@ FragmentInstance.prototype.dispatchEvent = function (
3156 (eventListeners !== null && eventListeners.length > 0) ||
3157 !event.bubbles
3158 ) {
3159 - const temp = document.createTextNode('');
3159 + // The temporary node stands in for the fragment's position so that its own
3160 + // listeners fire before the event propagates to the parent. A Document can
3161 + // only hold comments and processing instructions alongside its
3162 + // documentElement, so a Text node would be an invalid child there.
3163 + const temp =
3164 + parentHostInstance.nodeType === DOCUMENT_NODE
3165 + ? (parentHostInstance as any as Document).createComment('')
3166 + : document.createTextNode('');
3167 if (eventListeners) {
3168 for (let i = 0; i < eventListeners.length; i++) {
3169 const {type, listener, optionsOrUseCapture} = eventListeners[i];
packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js
+101
@@ -34,6 +34,7 @@ describe('FragmentRefs', () => {
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
@@ -103,6 +104,106 @@ describe('FragmentRefs', () => {
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()', () => {