Skip to content

Commit

Permalink
Warn for Child Iterator of all types but allow Generator Components (#…
Browse files Browse the repository at this point in the history
…28853)

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.
  • Loading branch information
sebmarkbage committed Apr 21, 2024
1 parent 857ee8c commit 3682021
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 73 deletions.
81 changes: 81 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7984,4 +7984,85 @@ describe('ReactDOMFizzServer', () => {
]);
expect(postpones).toEqual([]);
});

it('should NOT warn for using generator functions as components', async () => {
function* Foo() {
yield <h1 key="1">Hello</h1>;
yield <h1 key="2">World</h1>;
}

await act(() => {
const {pipe} = renderToPipeableStream(<Foo />);
pipe(writable);
});

expect(document.body.textContent).toBe('HelloWorld');
});

it('should warn for using generators as children props', async () => {
function* getChildren() {
yield <h1 key="1">Hello</h1>;
yield <h1 key="2">World</h1>;
}

function Foo() {
const children = getChildren();
return <div>{children}</div>;
}

await expect(async () => {
await act(() => {
const {pipe} = renderToPipeableStream(<Foo />);
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: <h1 key="1">Hello</h1>};
case 1:
return {done: false, value: <h1 key="2">World</h1>};
default:
return {done: true, value: undefined};
}
},
};
return iterator;
}

await expect(async () => {
await act(() => {
const {pipe} = renderToPipeableStream(<Foo />);
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');
});
});
78 changes: 73 additions & 5 deletions packages/react-dom/src/__tests__/ReactMultiChild-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,26 +328,94 @@ 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 <h1 key="1">Hello</h1>;
yield <h1 key="2">World</h1>;
}

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<Foo />);
});

expect(container.textContent).toBe('HelloWorld');
});

it('should warn for using generators as children props', async () => {
function* getChildren() {
yield <h1 key="1">Hello</h1>;
yield <h1 key="2">World</h1>;
}

function Foo() {
const children = getChildren();
return <div>{children}</div>;
}

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await expect(async () => {
await act(async () => {
root.render(<Foo />);
});
}).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(<Foo />);
});
});

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: <h1 key="1">Hello</h1>};
case 1:
return {done: false, value: <h1 key="2">World</h1>};
default:
return {done: true, value: undefined};
}
},
};
return iterator;
}

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await expect(async () => {
await act(async () => {
root.render(<Foo />);
});
}).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(<Foo />);
Expand Down
103 changes: 64 additions & 39 deletions packages/react-reconciler/src/ReactChildFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> | 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.');
}
Expand All @@ -1172,11 +1172,20 @@ function createChildReconciler(
let newIdx = 0;
let nextOldFiber = null;

let knownKeys: Set<string> | 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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading

0 comments on commit 3682021

Please sign in to comment.