@samitouri / QOS-React-2 / commits / 5711811da1

Reconcile element types of lazy component yielding the same type (#20357)

* Reconcile element types of lazy component yielding the same type * Add some legacy mode and suspense boundary flushing tests * Fix infinite loop in legacy mode In legacy mode we typically commit the suspending fiber and then rerender the nearest boundary to render the fallback in a separate commit. We can't do that when the boundary itself suspends because when we try to do the second pass, it'll suspend again and infinite loop. Interestingly the legacy semantics are not needed in this case because they exist to let an existing partial render fully commit its partial state. In this case there's no partial state, so we can just render the fallback immediately instead. * Check fast refresh compatibility first resolveLazy can suspend and if it does, it can resuspend. Fast refresh assumes that we don't resuspend. Instead it relies on updating the inner component later. * Use timers instead of act to force fallbacks to show

Sebastian Markbåge committed Dec 1, 2020 at 10:14 UTC 5711811da17c62de7ba6c911dbc7f20f16bfc980
5 files changed +407 -102
packages/react-reconciler/src/ReactChildFiber.new.js
+64 -50
@@ -246,6 +246,12 @@ function warnOnFunctionType(returnFiber: Fiber) {
246 }
247 }
248
249 +function resolveLazy(lazyType) {
250 + const payload = lazyType._payload;
251 + const init = lazyType._init;
252 + return init(payload);
253 +}
254 +
255 // This wrapper function exists because I expect to clone the code in each path
256 // to be able to optimize each path individually by branching early. This needs
257 // a compiler or we can do it manually. Helpers that don't need this branching
@@ -383,11 +389,32 @@ function ChildReconciler(shouldTrackSideEffects) {
389 element: ReactElement,
390 lanes: Lanes,
391 ): Fiber {
392 + const elementType = element.type;
393 + if (elementType === REACT_FRAGMENT_TYPE) {
394 + return updateFragment(
395 + returnFiber,
396 + current,
397 + element.props.children,
398 + lanes,
399 + element.key,
400 + );
401 + }
402 if (current !== null) {
403 if (
388 - current.elementType === element.type ||
404 + current.elementType === elementType ||
405 // Keep this check inline so it only runs on the false path:
390 - (__DEV__ ? isCompatibleFamilyForHotReloading(current, element) : false)
406 + (__DEV__
407 + ? isCompatibleFamilyForHotReloading(current, element)
408 + : false) ||
409 + // Lazy types should reconcile their resolved type.
410 + // We need to do this after the Hot Reloading check above,
411 + // because hot reloading has different semantics than prod because
412 + // it doesn't resuspend. So we can't let the call below suspend.
413 + (enableLazyElements &&
414 + typeof elementType === 'object' &&
415 + elementType !== null &&
416 + elementType.$$typeof === REACT_LAZY_TYPE &&
417 + resolveLazy(elementType) === current.type)
418 ) {
419 // Move based on index
420 const existing = useFiber(current, element.props);
@@ -551,15 +578,6 @@ function ChildReconciler(shouldTrackSideEffects) {
578 switch (newChild.$$typeof) {
579 case REACT_ELEMENT_TYPE: {
580 if (newChild.key === key) {
554 - if (newChild.type === REACT_FRAGMENT_TYPE) {
555 - return updateFragment(
556 - returnFiber,
557 - oldFiber,
558 - newChild.props.children,
559 - lanes,
560 - key,
561 - );
562 - }
581 return updateElement(returnFiber, oldFiber, newChild, lanes);
582 } else {
583 return null;
@@ -622,15 +640,6 @@ function ChildReconciler(shouldTrackSideEffects) {
640 existingChildren.get(
641 newChild.key === null ? newIdx : newChild.key,
642 ) || null;
625 - if (newChild.type === REACT_FRAGMENT_TYPE) {
626 - return updateFragment(
627 - returnFiber,
628 - matchedFiber,
629 - newChild.props.children,
630 - lanes,
631 - newChild.key,
632 - );
633 - }
643 return updateElement(returnFiber, matchedFiber, newChild, lanes);
644 }
645 case REACT_PORTAL_TYPE: {
@@ -1101,39 +1110,44 @@ function ChildReconciler(shouldTrackSideEffects) {
1110 // TODO: If key === null and child.key === null, then this only applies to
1111 // the first item in the list.
1112 if (child.key === key) {
1104 - switch (child.tag) {
1105 - case Fragment: {
1106 - if (element.type === REACT_FRAGMENT_TYPE) {
1107 - deleteRemainingChildren(returnFiber, child.sibling);
1108 - const existing = useFiber(child, element.props.children);
1109 - existing.return = returnFiber;
1110 - if (__DEV__) {
1111 - existing._debugSource = element._source;
1112 - existing._debugOwner = element._owner;
1113 - }
1114 - return existing;
1113 + const elementType = element.type;
1114 + if (elementType === REACT_FRAGMENT_TYPE) {
1115 + if (child.tag === Fragment) {
1116 + deleteRemainingChildren(returnFiber, child.sibling);
1117 + const existing = useFiber(child, element.props.children);
1118 + existing.return = returnFiber;
1119 + if (__DEV__) {
1120 + existing._debugSource = element._source;
1121 + existing._debugOwner = element._owner;
1122 }
1116 - break;
1123 + return existing;
1124 }
1118 - default: {
1119 - if (
1120 - child.elementType === element.type ||
1121 - // Keep this check inline so it only runs on the false path:
1122 - (__DEV__
1123 - ? isCompatibleFamilyForHotReloading(child, element)
1124 - : false)
1125 - ) {
1126 - deleteRemainingChildren(returnFiber, child.sibling);
1127 - const existing = useFiber(child, element.props);
1128 - existing.ref = coerceRef(returnFiber, child, element);
1129 - existing.return = returnFiber;
1130 - if (__DEV__) {
1131 - existing._debugSource = element._source;
1132 - existing._debugOwner = element._owner;
1133 - }
1134 - return existing;
1125 + } else {
1126 + if (
1127 + child.elementType === elementType ||
1128 + // Keep this check inline so it only runs on the false path:
1129 + (__DEV__
1130 + ? isCompatibleFamilyForHotReloading(child, element)
1131 + : false) ||
1132 + // Lazy types should reconcile their resolved type.
1133 + // We need to do this after the Hot Reloading check above,
1134 + // because hot reloading has different semantics than prod because
1135 + // it doesn't resuspend. So we can't let the call below suspend.
1136 + (enableLazyElements &&
1137 + typeof elementType === 'object' &&
1138 + elementType !== null &&
1139 + elementType.$$typeof === REACT_LAZY_TYPE &&
1140 + resolveLazy(elementType) === child.type)
1141 + ) {
1142 + deleteRemainingChildren(returnFiber, child.sibling);
1143 + const existing = useFiber(child, element.props);
1144 + existing.ref = coerceRef(returnFiber, child, element);
1145 + existing.return = returnFiber;
1146 + if (__DEV__) {
1147 + existing._debugSource = element._source;
1148 + existing._debugOwner = element._owner;
1149 }
1136 - break;
1150 + return existing;
1151 }
1152 }
1153 // Didn't match.
packages/react-reconciler/src/ReactChildFiber.old.js
+63 -50
@@ -246,6 +246,12 @@ function warnOnFunctionType(returnFiber: Fiber) {
246 }
247 }
248
249 +function resolveLazy(lazyType) {
250 + const payload = lazyType._payload;
251 + const init = lazyType._init;
252 + return init(payload);
253 +}
254 +
255 // This wrapper function exists because I expect to clone the code in each path
256 // to be able to optimize each path individually by branching early. This needs
257 // a compiler or we can do it manually. Helpers that don't need this branching
@@ -383,11 +389,32 @@ function ChildReconciler(shouldTrackSideEffects) {
389 element: ReactElement,
390 lanes: Lanes,
391 ): Fiber {
392 + const elementType = element.type;
393 + if (elementType === REACT_FRAGMENT_TYPE) {
394 + return updateFragment(
395 + returnFiber,
396 + current,
397 + element.props.children,
398 + lanes,
399 + element.key,
400 + );
401 + }
402 if (current !== null) {
403 if (
388 - current.elementType === element.type ||
404 + current.elementType === elementType ||
405 // Keep this check inline so it only runs on the false path:
390 - (__DEV__ ? isCompatibleFamilyForHotReloading(current, element) : false)
406 + (__DEV__
407 + ? isCompatibleFamilyForHotReloading(current, element)
408 + : false) ||
409 + // Lazy types should reconcile their resolved type.
410 + // We need to do this after the Hot Reloading check above,
411 + // because hot reloading has different semantics than prod because
412 + // it doesn't resuspend. So we can't let the call below suspend.
413 + (enableLazyElements &&
414 + typeof elementType === 'object' &&
415 + elementType !== null &&
416 + elementType.$$typeof === REACT_LAZY_TYPE &&
417 + resolveLazy(elementType) === current.type)
418 ) {
419 // Move based on index
420 const existing = useFiber(current, element.props);
@@ -551,15 +578,6 @@ function ChildReconciler(shouldTrackSideEffects) {
578 switch (newChild.$$typeof) {
579 case REACT_ELEMENT_TYPE: {
580 if (newChild.key === key) {
554 - if (newChild.type === REACT_FRAGMENT_TYPE) {
555 - return updateFragment(
556 - returnFiber,
557 - oldFiber,
558 - newChild.props.children,
559 - lanes,
560 - key,
561 - );
562 - }
581 return updateElement(returnFiber, oldFiber, newChild, lanes);
582 } else {
583 return null;
@@ -622,15 +640,6 @@ function ChildReconciler(shouldTrackSideEffects) {
640 existingChildren.get(
641 newChild.key === null ? newIdx : newChild.key,
642 ) || null;
625 - if (newChild.type === REACT_FRAGMENT_TYPE) {
626 - return updateFragment(
627 - returnFiber,
628 - matchedFiber,
629 - newChild.props.children,
630 - lanes,
631 - newChild.key,
632 - );
633 - }
643 return updateElement(returnFiber, matchedFiber, newChild, lanes);
644 }
645 case REACT_PORTAL_TYPE: {
@@ -1101,39 +1110,43 @@ function ChildReconciler(shouldTrackSideEffects) {
1110 // TODO: If key === null and child.key === null, then this only applies to
1111 // the first item in the list.
1112 if (child.key === key) {
1104 - switch (child.tag) {
1105 - case Fragment: {
1106 - if (element.type === REACT_FRAGMENT_TYPE) {
1107 - deleteRemainingChildren(returnFiber, child.sibling);
1108 - const existing = useFiber(child, element.props.children);
1109 - existing.return = returnFiber;
1110 - if (__DEV__) {
1111 - existing._debugSource = element._source;
1112 - existing._debugOwner = element._owner;
1113 - }
1114 - return existing;
1113 + const elementType = element.type;
1114 + if (elementType === REACT_FRAGMENT_TYPE) {
1115 + if (child.tag === Fragment) {
1116 + deleteRemainingChildren(returnFiber, child.sibling);
1117 + const existing = useFiber(child, element.props.children);
1118 + existing.return = returnFiber;
1119 + if (__DEV__) {
1120 + existing._debugSource = element._source;
1121 + existing._debugOwner = element._owner;
1122 }
1116 - break;
1123 + return existing;
1124 }
1118 - default: {
1119 - if (
1120 - child.elementType === element.type ||
1121 - // Keep this check inline so it only runs on the false path:
1122 - (__DEV__
1123 - ? isCompatibleFamilyForHotReloading(child, element)
1124 - : false)
1125 - ) {
1126 - deleteRemainingChildren(returnFiber, child.sibling);
1127 - const existing = useFiber(child, element.props);
1128 - existing.ref = coerceRef(returnFiber, child, element);
1129 - existing.return = returnFiber;
1130 - if (__DEV__) {
1131 - existing._debugSource = element._source;
1132 - existing._debugOwner = element._owner;
1133 - }
1134 - return existing;
1125 + } else {
1126 + if (
1127 + child.elementType === elementType ||
1128 + (__DEV__
1129 + ? isCompatibleFamilyForHotReloading(child, element)
1130 + : false) ||
1131 + // Lazy types should reconcile their resolved type.
1132 + // We need to do this after the Hot Reloading check above,
1133 + // because hot reloading has different semantics than prod because
1134 + // it doesn't resuspend. So we can't let the call below suspend.
1135 + (enableLazyElements &&
1136 + typeof elementType === 'object' &&
1137 + elementType !== null &&
1138 + elementType.$$typeof === REACT_LAZY_TYPE &&
1139 + resolveLazy(elementType) === child.type)
1140 + ) {
1141 + deleteRemainingChildren(returnFiber, child.sibling);
1142 + const existing = useFiber(child, element.props);
1143 + existing.ref = coerceRef(returnFiber, child, element);
1144 + existing.return = returnFiber;
1145 + if (__DEV__) {
1146 + existing._debugSource = element._source;
1147 + existing._debugOwner = element._owner;
1148 }
1136 - break;
1149 + return existing;
1150 }
1151 }
1152 // Didn't match.
packages/react-reconciler/src/ReactFiberThrow.new.js
+9 -1
@@ -256,7 +256,15 @@ function throwException(
256 // Note: It doesn't matter whether the component that suspended was
257 // inside a blocking mode tree. If the Suspense is outside of it, we
258 // should *not* suspend the commit.
259 - if ((workInProgress.mode & BlockingMode) === NoMode) {
259 + //
260 + // If the suspense boundary suspended itself suspended, we don't have to
261 + // do this trick because nothing was partially started. We can just
262 + // directly do a second pass over the fallback in this render and
263 + // pretend we meant to render that directly.
264 + if (
265 + (workInProgress.mode & BlockingMode) === NoMode &&
266 + workInProgress !== returnFiber
267 + ) {
268 workInProgress.flags |= DidCapture;
269 sourceFiber.flags |= ForceUpdateForLegacySuspense;
270
packages/react-reconciler/src/ReactFiberThrow.old.js
+9 -1
@@ -256,7 +256,15 @@ function throwException(
256 // Note: It doesn't matter whether the component that suspended was
257 // inside a blocking mode tree. If the Suspense is outside of it, we
258 // should *not* suspend the commit.
259 - if ((workInProgress.mode & BlockingMode) === NoMode) {
259 + //
260 + // If the suspense boundary suspended itself suspended, we don't have to
261 + // do this trick because nothing was partially started. We can just
262 + // directly do a second pass over the fallback in this render and
263 + // pretend we meant to render that directly.
264 + if (
265 + (workInProgress.mode & BlockingMode) === NoMode &&
266 + workInProgress !== returnFiber
267 + ) {
268 workInProgress.flags |= DidCapture;
269 sourceFiber.flags |= ForceUpdateForLegacySuspense;
270
packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+262
@@ -1268,6 +1268,192 @@ describe('ReactLazy', () => {
1268 expect(componentStackMessage).toContain('in Lazy');
1269 });
1270
1271 + // @gate enableLazyElements
1272 + it('mount and reorder lazy types', async () => {
1273 + class Child extends React.Component {
1274 + componentDidMount() {
1275 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
1276 + }
1277 + componentDidUpdate() {
1278 + Scheduler.unstable_yieldValue('Did update: ' + this.props.label);
1279 + }
1280 + render() {
1281 + return <Text text={this.props.label} />;
1282 + }
1283 + }
1284 +
1285 + function ChildA({lowerCase}) {
1286 + return <Child label={lowerCase ? 'a' : 'A'} />;
1287 + }
1288 +
1289 + function ChildB({lowerCase}) {
1290 + return <Child label={lowerCase ? 'b' : 'B'} />;
1291 + }
1292 +
1293 + const LazyChildA = lazy(() => {
1294 + Scheduler.unstable_yieldValue('Init A');
1295 + return fakeImport(ChildA);
1296 + });
1297 + const LazyChildB = lazy(() => {
1298 + Scheduler.unstable_yieldValue('Init B');
1299 + return fakeImport(ChildB);
1300 + });
1301 + const LazyChildA2 = lazy(() => {
1302 + Scheduler.unstable_yieldValue('Init A2');
1303 + return fakeImport(ChildA);
1304 + });
1305 + let resolveB2;
1306 + const LazyChildB2 = lazy(() => {
1307 + Scheduler.unstable_yieldValue('Init B2');
1308 + return new Promise(r => {
1309 + resolveB2 = r;
1310 + });
1311 + });
1312 +
1313 + function Parent({swap}) {
1314 + return (
1315 + <Suspense fallback={<Text text="Outer..." />}>
1316 + <Suspense fallback={<Text text="Loading..." />}>
1317 + {swap
1318 + ? [
1319 + <LazyChildB2 key="B" lowerCase={true} />,
1320 + <LazyChildA2 key="A" lowerCase={true} />,
1321 + ]
1322 + : [<LazyChildA key="A" />, <LazyChildB key="B" />]}
1323 + </Suspense>
1324 + </Suspense>
1325 + );
1326 + }
1327 +
1328 + const root = ReactTestRenderer.create(<Parent swap={false} />, {
1329 + unstable_isConcurrent: true,
1330 + });
1331 +
1332 + expect(Scheduler).toFlushAndYield(['Init A', 'Init B', 'Loading...']);
1333 + expect(root).not.toMatchRenderedOutput('AB');
1334 +
1335 + await LazyChildA;
1336 + await LazyChildB;
1337 +
1338 + expect(Scheduler).toFlushAndYield([
1339 + 'A',
1340 + 'B',
1341 + 'Did mount: A',
1342 + 'Did mount: B',
1343 + ]);
1344 + expect(root).toMatchRenderedOutput('AB');
1345 +
1346 + // Swap the position of A and B
1347 + root.update(<Parent swap={true} />);
1348 + expect(Scheduler).toFlushAndYield(['Init B2', 'Loading...']);
1349 + jest.runAllTimers();
1350 +
1351 + // The suspense boundary should've triggered now.
1352 + expect(root).toMatchRenderedOutput('Loading...');
1353 + await resolveB2({default: ChildB});
1354 +
1355 + // We need to flush to trigger the second one to load.
1356 + expect(Scheduler).toFlushAndYield(['Init A2']);
1357 + await LazyChildA2;
1358 +
1359 + expect(Scheduler).toFlushAndYield([
1360 + 'b',
1361 + 'a',
1362 + 'Did update: b',
1363 + 'Did update: a',
1364 + ]);
1365 + expect(root).toMatchRenderedOutput('ba');
1366 + });
1367 +
1368 + // @gate enableLazyElements
1369 + it('mount and reorder lazy types (legacy mode)', async () => {
1370 + class Child extends React.Component {
1371 + componentDidMount() {
1372 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
1373 + }
1374 + componentDidUpdate() {
1375 + Scheduler.unstable_yieldValue('Did update: ' + this.props.label);
1376 + }
1377 + render() {
1378 + return <Text text={this.props.label} />;
1379 + }
1380 + }
1381 +
1382 + function ChildA({lowerCase}) {
1383 + return <Child label={lowerCase ? 'a' : 'A'} />;
1384 + }
1385 +
1386 + function ChildB({lowerCase}) {
1387 + return <Child label={lowerCase ? 'b' : 'B'} />;
1388 + }
1389 +
1390 + const LazyChildA = lazy(() => {
1391 + Scheduler.unstable_yieldValue('Init A');
1392 + return fakeImport(ChildA);
1393 + });
1394 + const LazyChildB = lazy(() => {
1395 + Scheduler.unstable_yieldValue('Init B');
1396 + return fakeImport(ChildB);
1397 + });
1398 + const LazyChildA2 = lazy(() => {
1399 + Scheduler.unstable_yieldValue('Init A2');
1400 + return fakeImport(ChildA);
1401 + });
1402 + const LazyChildB2 = lazy(() => {
1403 + Scheduler.unstable_yieldValue('Init B2');
1404 + return fakeImport(ChildB);
1405 + });
1406 +
1407 + function Parent({swap}) {
1408 + return (
1409 + <Suspense fallback={<Text text="Outer..." />}>
1410 + <Suspense fallback={<Text text="Loading..." />}>
1411 + {swap
1412 + ? [
1413 + <LazyChildB2 key="B" lowerCase={true} />,
1414 + <LazyChildA2 key="A" lowerCase={true} />,
1415 + ]
1416 + : [<LazyChildA key="A" />, <LazyChildB key="B" />]}
1417 + </Suspense>
1418 + </Suspense>
1419 + );
1420 + }
1421 +
1422 + const root = ReactTestRenderer.create(<Parent swap={false} />, {
1423 + unstable_isConcurrent: false,
1424 + });
1425 +
1426 + expect(Scheduler).toHaveYielded(['Init A', 'Init B', 'Loading...']);
1427 + expect(root).not.toMatchRenderedOutput('AB');
1428 +
1429 + await LazyChildA;
1430 + await LazyChildB;
1431 +
1432 + expect(Scheduler).toFlushAndYield([
1433 + 'A',
1434 + 'B',
1435 + 'Did mount: A',
1436 + 'Did mount: B',
1437 + ]);
1438 + expect(root).toMatchRenderedOutput('AB');
1439 +
1440 + // Swap the position of A and B
1441 + root.update(<Parent swap={true} />);
1442 + expect(Scheduler).toHaveYielded(['Init B2', 'Loading...']);
1443 + await LazyChildB2;
1444 + // We need to flush to trigger the second one to load.
1445 + expect(Scheduler).toFlushAndYield(['Init A2']);
1446 + await LazyChildA2;
1447 +
1448 + expect(Scheduler).toFlushAndYield([
1449 + 'b',
1450 + 'a',
1451 + 'Did update: b',
1452 + 'Did update: a',
1453 + ]);
1454 + expect(root).toMatchRenderedOutput('ba');
1455 + });
1456 +
1457 // @gate enableLazyElements
1458 it('mount and reorder lazy elements', async () => {
1459 class Child extends React.Component {
@@ -1343,4 +1529,80 @@ describe('ReactLazy', () => {
1529 ]);
1530 expect(root).toMatchRenderedOutput('ba');
1531 });
1532 +
1533 + // @gate enableLazyElements
1534 + it('mount and reorder lazy elements (legacy mode)', async () => {
1535 + class Child extends React.Component {
1536 + componentDidMount() {
1537 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
1538 + }
1539 + componentDidUpdate() {
1540 + Scheduler.unstable_yieldValue('Did update: ' + this.props.label);
1541 + }
1542 + render() {
1543 + return <Text text={this.props.label} />;
1544 + }
1545 + }
1546 +
1547 + const lazyChildA = lazy(() => {
1548 + Scheduler.unstable_yieldValue('Init A');
1549 + return fakeImport(<Child key="A" label="A" />);
1550 + });
1551 + const lazyChildB = lazy(() => {
1552 + Scheduler.unstable_yieldValue('Init B');
1553 + return fakeImport(<Child key="B" label="B" />);
1554 + });
1555 + const lazyChildA2 = lazy(() => {
1556 + Scheduler.unstable_yieldValue('Init A2');
1557 + return fakeImport(<Child key="A" label="a" />);
1558 + });
1559 + const lazyChildB2 = lazy(() => {
1560 + Scheduler.unstable_yieldValue('Init B2');
1561 + return fakeImport(<Child key="B" label="b" />);
1562 + });
1563 +
1564 + function Parent({swap}) {
1565 + return (
1566 + <Suspense fallback={<Text text="Loading..." />}>
1567 + {swap ? [lazyChildB2, lazyChildA2] : [lazyChildA, lazyChildB]}
1568 + </Suspense>
1569 + );
1570 + }
1571 +
1572 + const root = ReactTestRenderer.create(<Parent swap={false} />, {
1573 + unstable_isConcurrent: false,
1574 + });
1575 +
1576 + expect(Scheduler).toHaveYielded(['Init A', 'Loading...']);
1577 + expect(root).not.toMatchRenderedOutput('AB');
1578 +
1579 + await lazyChildA;
1580 + // We need to flush to trigger the B to load.
1581 + expect(Scheduler).toFlushAndYield(['Init B']);
1582 + await lazyChildB;
1583 +
1584 + expect(Scheduler).toFlushAndYield([
1585 + 'A',
1586 + 'B',
1587 + 'Did mount: A',
1588 + 'Did mount: B',
1589 + ]);
1590 + expect(root).toMatchRenderedOutput('AB');
1591 +
1592 + // Swap the position of A and B
1593 + root.update(<Parent swap={true} />);
1594 + expect(Scheduler).toHaveYielded(['Init B2', 'Loading...']);
1595 + await lazyChildB2;
1596 + // We need to flush to trigger the second one to load.
1597 + expect(Scheduler).toFlushAndYield(['Init A2']);
1598 + await lazyChildA2;
1599 +
1600 + expect(Scheduler).toFlushAndYield([
1601 + 'b',
1602 + 'a',
1603 + 'Did update: b',
1604 + 'Did update: a',
1605 + ]);
1606 + expect(root).toMatchRenderedOutput('ba');
1607 + });
1608 });