From 368202181e772d411b2445930aea1edd9428b09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 21 Apr 2024 12:51:45 -0400 Subject: [PATCH] Warn for Child Iterator of all types but allow Generator Components (#28853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This doesn't change production behavior. We always render Iterables to our best effort in prod even if they're Iterators. But this does change the DEV warnings which indicates which are valid patterns to use. It's a footgun to use an Iterator as a prop when you pass between components because if an intermediate component rerenders without its parent, React won't be able to iterate it again to reconcile and any mappers won't be able to re-apply. This is actually typically not a problem when passed only to React host components but as a pattern it's a problem for composability. We used to warn only for Generators - i.e. Iterators returned from Generator functions. This adds a warning for Iterators created by other means too (e.g. Flight or the native Iterator utils). The heuristic is to check whether the Iterator is the same as the Iterable because that means it's not possible to get new iterators out of it. This case used to just yield non-sense like empty sets in DEV but not in prod. However, a new realization is that when the Component itself is a Generator Function, it's not actually a problem. That's because the React Element itself works as an Iterable since we can ask for new generators by calling the function again. So this adds a special case to allow the Generator returned from a Generator Function's direct child. The principle is “don’t pass iterators around” but in this case there is no iterator floating around because it’s between React and the JS VM. Also see #28849 for context on AsyncIterables. Related to this, but Hooks should ideally be banned in these for the same reason they're banned in Async Functions. --- .../src/__tests__/ReactDOMFizzServer-test.js | 81 ++++++++++++++ .../src/__tests__/ReactMultiChild-test.js | 78 ++++++++++++- .../react-reconciler/src/ReactChildFiber.js | 103 +++++++++++------- packages/react-server/src/ReactFizzServer.js | 63 ++++++----- packages/react/src/jsx/ReactJSXElement.js | 10 +- 5 files changed, 262 insertions(+), 73 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index f5abbb3280d9d..64964031d59ed 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -7984,4 +7984,85 @@ describe('ReactDOMFizzServer', () => { ]); expect(postpones).toEqual([]); }); + + it('should NOT warn for using generator functions as components', async () => { + function* Foo() { + yield

Hello

; + yield

World

; + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + + expect(document.body.textContent).toBe('HelloWorld'); + }); + + it('should warn for using generators as children props', async () => { + function* getChildren() { + yield

Hello

; + yield

World

; + } + + function Foo() { + const children = getChildren(); + return
{children}
; + } + + await expect(async () => { + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + }).toErrorDev( + 'Using Iterators as children is unsupported and will likely yield ' + + 'unexpected results because enumerating a generator mutates it. ' + + 'You may convert it to an array with `Array.from()` or the ' + + '`[...spread]` operator before rendering. You can also use an ' + + 'Iterable that can iterate multiple times over the same items.\n' + + ' in div (at **)\n' + + ' in Foo (at **)', + ); + + expect(document.body.textContent).toBe('HelloWorld'); + }); + + it('should warn for using other types of iterators as children', async () => { + function Foo() { + let i = 0; + const iterator = { + [Symbol.iterator]() { + return iterator; + }, + next() { + switch (i++) { + case 0: + return {done: false, value:

Hello

}; + case 1: + return {done: false, value:

World

}; + default: + return {done: true, value: undefined}; + } + }, + }; + return iterator; + } + + await expect(async () => { + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + }).toErrorDev( + 'Using Iterators as children is unsupported and will likely yield ' + + 'unexpected results because enumerating a generator mutates it. ' + + 'You may convert it to an array with `Array.from()` or the ' + + '`[...spread]` operator before rendering. You can also use an ' + + 'Iterable that can iterate multiple times over the same items.\n' + + ' in Foo (at **)', + ); + + expect(document.body.textContent).toBe('HelloWorld'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactMultiChild-test.js b/packages/react-dom/src/__tests__/ReactMultiChild-test.js index 96413378f6aef..44d71fe7fc805 100644 --- a/packages/react-dom/src/__tests__/ReactMultiChild-test.js +++ b/packages/react-dom/src/__tests__/ReactMultiChild-test.js @@ -328,12 +328,77 @@ describe('ReactMultiChild', () => { ); }); - it('should warn for using generators as children', async () => { + it('should NOT warn for using generator functions as components', async () => { function* Foo() { yield

Hello

; yield

World

; } + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + + expect(container.textContent).toBe('HelloWorld'); + }); + + it('should warn for using generators as children props', async () => { + function* getChildren() { + yield

Hello

; + yield

World

; + } + + function Foo() { + const children = getChildren(); + return
{children}
; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(async () => { + root.render(); + }); + }).toErrorDev( + 'Using Iterators as children is unsupported and will likely yield ' + + 'unexpected results because enumerating a generator mutates it. ' + + 'You may convert it to an array with `Array.from()` or the ' + + '`[...spread]` operator before rendering. You can also use an ' + + 'Iterable that can iterate multiple times over the same items.\n' + + ' in div (at **)\n' + + ' in Foo (at **)', + ); + + expect(container.textContent).toBe('HelloWorld'); + + // Test de-duplication + await act(async () => { + root.render(); + }); + }); + + it('should warn for using other types of iterators as children', async () => { + function Foo() { + let i = 0; + const iterator = { + [Symbol.iterator]() { + return iterator; + }, + next() { + switch (i++) { + case 0: + return {done: false, value:

Hello

}; + case 1: + return {done: false, value:

World

}; + default: + return {done: true, value: undefined}; + } + }, + }; + return iterator; + } + const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); await expect(async () => { @@ -341,13 +406,16 @@ describe('ReactMultiChild', () => { root.render(); }); }).toErrorDev( - 'Using Generators as children is unsupported and will likely yield ' + - 'unexpected results because enumerating a generator mutates it. You may ' + - 'convert it to an array with `Array.from()` or the `[...spread]` operator ' + - 'before rendering. Keep in mind you might need to polyfill these features for older browsers.\n' + + 'Using Iterators as children is unsupported and will likely yield ' + + 'unexpected results because enumerating a generator mutates it. ' + + 'You may convert it to an array with `Array.from()` or the ' + + '`[...spread]` operator before rendering. You can also use an ' + + 'Iterable that can iterate multiple times over the same items.\n' + ' in Foo (at **)', ); + expect(container.textContent).toBe('HelloWorld'); + // Test de-duplication await act(async () => { root.render(); diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index e7534ac9e4347..f0fe8883b9eb4 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -33,7 +33,13 @@ import { REACT_LAZY_TYPE, REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; -import {HostRoot, HostText, HostPortal, Fragment} from './ReactWorkTags'; +import { + HostRoot, + HostText, + HostPortal, + Fragment, + FunctionComponent, +} from './ReactWorkTags'; import isArray from 'shared/isArray'; import {enableRefAsProp} from 'shared/ReactFeatureFlags'; @@ -1114,52 +1120,46 @@ function createChildReconciler( ); } + const newChildren = iteratorFn.call(newChildrenIterable); + if (__DEV__) { - // We don't support rendering Generators because it's a mutation. - // See https://github.com/facebook/react/issues/12995 - if ( - typeof Symbol === 'function' && - // $FlowFixMe[prop-missing] Flow doesn't know about toStringTag - newChildrenIterable[Symbol.toStringTag] === 'Generator' - ) { - if (!didWarnAboutGenerators) { - console.error( - 'Using Generators as children is unsupported and will likely yield ' + - 'unexpected results because enumerating a generator mutates it. ' + - 'You may convert it to an array with `Array.from()` or the ' + - '`[...spread]` operator before rendering. Keep in mind ' + - 'you might need to polyfill these features for older browsers.', - ); + if (newChildren === newChildrenIterable) { + // We don't support rendering Generators as props because it's a mutation. + // See https://github.com/facebook/react/issues/12995 + // We do support generators if they were created by a GeneratorFunction component + // as its direct child since we can recreate those by rerendering the component + // as needed. + const isGeneratorComponent = + returnFiber.tag === FunctionComponent && + // $FlowFixMe[method-unbinding] + Object.prototype.toString.call(returnFiber.type) === + '[object GeneratorFunction]' && + // $FlowFixMe[method-unbinding] + Object.prototype.toString.call(newChildren) === '[object Generator]'; + if (!isGeneratorComponent) { + if (!didWarnAboutGenerators) { + console.error( + 'Using Iterators as children is unsupported and will likely yield ' + + 'unexpected results because enumerating a generator mutates it. ' + + 'You may convert it to an array with `Array.from()` or the ' + + '`[...spread]` operator before rendering. You can also use an ' + + 'Iterable that can iterate multiple times over the same items.', + ); + } + didWarnAboutGenerators = true; } - didWarnAboutGenerators = true; - } - - // Warn about using Maps as children - if ((newChildrenIterable: any).entries === iteratorFn) { + } else if ((newChildrenIterable: any).entries === iteratorFn) { + // Warn about using Maps as children if (!didWarnAboutMaps) { console.error( 'Using Maps as children is not supported. ' + 'Use an array of keyed ReactElements instead.', ); - } - didWarnAboutMaps = true; - } - - // First, validate keys. - // We'll get a different iterator later for the main pass. - const newChildren = iteratorFn.call(newChildrenIterable); - if (newChildren) { - let knownKeys: Set | null = null; - let step = newChildren.next(); - for (; !step.done; step = newChildren.next()) { - const child = step.value; - knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber); + didWarnAboutMaps = true; } } } - const newChildren = iteratorFn.call(newChildrenIterable); - if (newChildren == null) { throw new Error('An iterable object provided no iterator.'); } @@ -1172,11 +1172,20 @@ function createChildReconciler( let newIdx = 0; let nextOldFiber = null; + let knownKeys: Set | null = null; + let step = newChildren.next(); + if (__DEV__) { + knownKeys = warnOnInvalidKey(step.value, knownKeys, returnFiber); + } for ( ; oldFiber !== null && !step.done; - newIdx++, step = newChildren.next() + newIdx++, + step = newChildren.next(), + knownKeys = __DEV__ + ? warnOnInvalidKey(step.value, knownKeys, returnFiber) + : null ) { if (oldFiber.index > newIdx) { nextOldFiber = oldFiber; @@ -1236,7 +1245,15 @@ function createChildReconciler( if (oldFiber === null) { // If we don't have any more existing children we can choose a fast path // since the rest will all be insertions. - for (; !step.done; newIdx++, step = newChildren.next()) { + for ( + ; + !step.done; + newIdx++, + step = newChildren.next(), + knownKeys = __DEV__ + ? warnOnInvalidKey(step.value, knownKeys, returnFiber) + : null + ) { const newFiber = createChild(returnFiber, step.value, lanes, debugInfo); if (newFiber === null) { continue; @@ -1261,7 +1278,15 @@ function createChildReconciler( const existingChildren = mapRemainingChildren(oldFiber); // Keep scanning and use the map to restore deleted items as moves. - for (; !step.done; newIdx++, step = newChildren.next()) { + for ( + ; + !step.done; + newIdx++, + step = newChildren.next(), + knownKeys = __DEV__ + ? warnOnInvalidKey(step.value, knownKeys, returnFiber) + : null + ) { const newFiber = updateFromMap( existingChildren, returnFiber, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7ba6f334e759a..00ed4d8f06f6d 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -2150,36 +2150,49 @@ function replayElement( // rendered in the prelude and skip it. } -// $FlowFixMe[missing-local-annot] -function validateIterable(iterable, iteratorFn: Function): void { +function validateIterable( + task: Task, + iterable: Iterable, + childIndex: number, + iterator: Iterator, + iteratorFn: () => ?Iterator, +): void { if (__DEV__) { - // We don't support rendering Generators because it's a mutation. - // See https://github.com/facebook/react/issues/12995 - if ( - typeof Symbol === 'function' && - iterable[Symbol.toStringTag] === 'Generator' - ) { - if (!didWarnAboutGenerators) { - console.error( - 'Using Generators as children is unsupported and will likely yield ' + - 'unexpected results because enumerating a generator mutates it. ' + - 'You may convert it to an array with `Array.from()` or the ' + - '`[...spread]` operator before rendering. Keep in mind ' + - 'you might need to polyfill these features for older browsers.', - ); + if (iterator === iterable) { + // We don't support rendering Generators as props because it's a mutation. + // See https://github.com/facebook/react/issues/12995 + // We do support generators if they were created by a GeneratorFunction component + // as its direct child since we can recreate those by rerendering the component + // as needed. + const isGeneratorComponent = + task.componentStack !== null && + task.componentStack.tag === 1 && // FunctionComponent + // $FlowFixMe[method-unbinding] + Object.prototype.toString.call(task.componentStack.type) === + '[object GeneratorFunction]' && + // $FlowFixMe[method-unbinding] + Object.prototype.toString.call(iterator) === '[object Generator]'; + if (!isGeneratorComponent) { + if (!didWarnAboutGenerators) { + console.error( + 'Using Iterators as children is unsupported and will likely yield ' + + 'unexpected results because enumerating a generator mutates it. ' + + 'You may convert it to an array with `Array.from()` or the ' + + '`[...spread]` operator before rendering. You can also use an ' + + 'Iterable that can iterate multiple times over the same items.', + ); + } + didWarnAboutGenerators = true; } - didWarnAboutGenerators = true; - } - - // Warn about using Maps as children - if ((iterable: any).entries === iteratorFn) { + } else if ((iterable: any).entries === iteratorFn) { + // Warn about using Maps as children if (!didWarnAboutMaps) { console.error( 'Using Maps as children is not supported. ' + 'Use an array of keyed ReactElements instead.', ); + didWarnAboutMaps = true; } - didWarnAboutMaps = true; } } } @@ -2303,11 +2316,11 @@ function renderNodeDestructive( const iteratorFn = getIteratorFn(node); if (iteratorFn) { - if (__DEV__) { - validateIterable(node, iteratorFn); - } const iterator = iteratorFn.call(node); if (iterator) { + if (__DEV__) { + validateIterable(task, node, childIndex, iterator, iteratorFn); + } // We need to know how many total children are in this set, so that we // can allocate enough id slots to acommodate them. So we must exhaust // the iterator before we start recursively rendering the children. diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index 48feb83cdde35..36185639e67f0 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -1028,10 +1028,12 @@ function validateChildKeys(node, parentType) { // but now we print a separate warning for them later. if (iteratorFn !== node.entries) { const iterator = iteratorFn.call(node); - let step; - while (!(step = iterator.next()).done) { - if (isValidElement(step.value)) { - validateExplicitKey(step.value, parentType); + if (iterator !== node) { + let step; + while (!(step = iterator.next()).done) { + if (isValidElement(step.value)) { + validateExplicitKey(step.value, parentType); + } } } }