Fix suspense replaying forward refs (#26535)
Continuation of https://github.com/facebook/react/issues/26420 Fixes https://github.com/facebook/react/issues/26385 and https://github.com/facebook/react/issues/26419 --------- Co-authored-by: eps1lon <silbermann.sebastian@gmail.com> Co-authored-by: Andrew Clark <git@andrewclark.io>
Hans Otto Wirtz committed
Apr 3, 2023 at 00:48 UTC
7329ea81c154d40800e30104be40f050e8c2af3e
3 files changed
+154
-13
packages/react-reconciler/src/ReactFiberBeginWork.js
+2
-7
@@ -1168,18 +1168,13 @@ export function replayFunctionComponent(
1168
workInProgress: Fiber,
1169
nextProps: any,
1170
Component: any,
1171
+ secondArg: any,
1172
renderLanes: Lanes,
1173
): Fiber | null {
1174
// This function is used to replay a component that previously suspended,
1175
// after its data resolves. It's a simplified version of
1176
// updateFunctionComponent that reuses the hooks from the previous attempt.
1177
1177
- let context;
1178
- if (!disableLegacyContext) {
1179
- const unmaskedContext = getUnmaskedContext(workInProgress, Component, true);
1180
- context = getMaskedContext(workInProgress, unmaskedContext);
1181
- }
1182
-
1178
prepareToReadContext(workInProgress, renderLanes);
1179
if (enableSchedulingProfiler) {
1180
markComponentRenderStarted(workInProgress);
@@ -1189,7 +1184,7 @@ export function replayFunctionComponent(
1184
workInProgress,
1185
Component,
1186
nextProps,
1192
- context,
1187
+ secondArg,
1188
);
1189
const hasId = checkDidRenderIdHook();
1190
if (enableSchedulingProfiler) {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+24
-6
@@ -39,6 +39,7 @@ import {
39
enableTransitionTracing,
40
useModernStrictMode,
41
revertRemovalOfSiblingPrerendering,
42
+ disableLegacyContext,
43
} from 'shared/ReactFeatureFlags';
44
import ReactSharedInternals from 'shared/ReactSharedInternals';
45
import is from 'shared/objectIs';
@@ -281,6 +282,7 @@ import {
282
flushSyncWorkOnLegacyRootsOnly,
283
getContinuationForRoot,
284
} from './ReactFiberRootScheduler';
285
+import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
286
287
const ceil = Math.ceil;
288
@@ -2380,8 +2382,8 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2382
// Fallthrough to the next branch.
2383
}
2384
// eslint-disable-next-line no-fallthrough
2383
- case FunctionComponent:
2384
- case ForwardRef: {
2385
+ case SimpleMemoComponent:
2386
+ case FunctionComponent: {
2387
// Resolve `defaultProps`. This logic is copied from `beginWork`.
2388
// TODO: Consider moving this switch statement into that module. Also,
2389
// could maybe use this as an opportunity to say `use` doesn't work with
@@ -2392,23 +2394,39 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2394
unitOfWork.elementType === Component
2395
? unresolvedProps
2396
: resolveDefaultProps(Component, unresolvedProps);
2397
+ let context: any;
2398
+ if (!disableLegacyContext) {
2399
+ const unmaskedContext = getUnmaskedContext(unitOfWork, Component, true);
2400
+ context = getMaskedContext(unitOfWork, unmaskedContext);
2401
+ }
2402
next = replayFunctionComponent(
2403
current,
2404
unitOfWork,
2405
resolvedProps,
2406
Component,
2407
+ context,
2408
workInProgressRootRenderLanes,
2409
);
2410
break;
2411
}
2404
- case SimpleMemoComponent: {
2405
- const Component = unitOfWork.type;
2406
- const nextProps = unitOfWork.pendingProps;
2412
+ case ForwardRef: {
2413
+ // Resolve `defaultProps`. This logic is copied from `beginWork`.
2414
+ // TODO: Consider moving this switch statement into that module. Also,
2415
+ // could maybe use this as an opportunity to say `use` doesn't work with
2416
+ // `defaultProps` :)
2417
+ const Component = unitOfWork.type.render;
2418
+ const unresolvedProps = unitOfWork.pendingProps;
2419
+ const resolvedProps =
2420
+ unitOfWork.elementType === Component
2421
+ ? unresolvedProps
2422
+ : resolveDefaultProps(Component, unresolvedProps);
2423
+
2424
next = replayFunctionComponent(
2425
current,
2426
unitOfWork,
2410
- nextProps,
2427
+ resolvedProps,
2428
Component,
2429
+ unitOfWork.ref,
2430
workInProgressRootRenderLanes,
2431
);
2432
break;
packages/react-reconciler/src/__tests__/ReactUse-test.js
+128
@@ -1472,4 +1472,132 @@ describe('ReactUse', () => {
1472
assertLog(['Hi']);
1473
expect(root).toMatchRenderedOutput('Hi');
1474
});
1475
+
1476
+ test('unwrap uncached promises inside forwardRef', async () => {
1477
+ const asyncInstance = {};
1478
+ const Async = React.forwardRef((props, ref) => {
1479
+ React.useImperativeHandle(ref, () => asyncInstance);
1480
+ const text = use(Promise.resolve('Async'));
1481
+ return <Text text={text} />;
1482
+ });
1483
+
1484
+ const ref = React.createRef();
1485
+ function App() {
1486
+ return (
1487
+ <Suspense fallback={<Text text="Loading..." />}>
1488
+ <Async ref={ref} />
1489
+ </Suspense>
1490
+ );
1491
+ }
1492
+
1493
+ const root = ReactNoop.createRoot();
1494
+ await act(() => {
1495
+ startTransition(() => {
1496
+ root.render(<App />);
1497
+ });
1498
+ });
1499
+ assertLog(['Async']);
1500
+ expect(root).toMatchRenderedOutput('Async');
1501
+ expect(ref.current).toBe(asyncInstance);
1502
+ });
1503
+
1504
+ test('unwrap uncached promises inside memo', async () => {
1505
+ const Async = React.memo(
1506
+ props => {
1507
+ const text = use(Promise.resolve(props.text));
1508
+ return <Text text={text} />;
1509
+ },
1510
+ (a, b) => a.text === b.text,
1511
+ );
1512
+
1513
+ function App({text}) {
1514
+ return (
1515
+ <Suspense fallback={<Text text="Loading..." />}>
1516
+ <Async text={text} />
1517
+ </Suspense>
1518
+ );
1519
+ }
1520
+
1521
+ const root = ReactNoop.createRoot();
1522
+ await act(() => {
1523
+ startTransition(() => {
1524
+ root.render(<App text="Async" />);
1525
+ });
1526
+ });
1527
+ assertLog(['Async']);
1528
+ expect(root).toMatchRenderedOutput('Async');
1529
+
1530
+ // Update to the same value
1531
+ await act(() => {
1532
+ startTransition(() => {
1533
+ root.render(<App text="Async" />);
1534
+ });
1535
+ });
1536
+ // Should not have re-rendered, because it's memoized
1537
+ assertLog([]);
1538
+ expect(root).toMatchRenderedOutput('Async');
1539
+
1540
+ // Update to a different value
1541
+ await act(() => {
1542
+ startTransition(() => {
1543
+ root.render(<App text="Async!" />);
1544
+ });
1545
+ });
1546
+ assertLog(['Async!']);
1547
+ expect(root).toMatchRenderedOutput('Async!');
1548
+ });
1549
+
1550
+ // @gate !disableLegacyContext
1551
+ test('unwrap uncached promises in component that accesses legacy context', async () => {
1552
+ class ContextProvider extends React.Component {
1553
+ static childContextTypes = {
1554
+ legacyContext() {},
1555
+ };
1556
+ getChildContext() {
1557
+ return {legacyContext: 'Async'};
1558
+ }
1559
+ render() {
1560
+ return this.props.children;
1561
+ }
1562
+ }
1563
+
1564
+ function Async({label}, context) {
1565
+ const text = use(Promise.resolve(context.legacyContext + ` (${label})`));
1566
+ return <Text text={text} />;
1567
+ }
1568
+ Async.contextTypes = {
1569
+ legacyContext: () => {},
1570
+ };
1571
+
1572
+ const AsyncMemo = React.memo(Async, (a, b) => a.label === b.label);
1573
+
1574
+ function App() {
1575
+ return (
1576
+ <ContextProvider>
1577
+ <Suspense fallback={<Text text="Loading..." />}>
1578
+ <div>
1579
+ <Async label="function component" />
1580
+ </div>
1581
+ <div>
1582
+ <AsyncMemo label="memo component" />
1583
+ </div>
1584
+ </Suspense>
1585
+ </ContextProvider>
1586
+ );
1587
+ }
1588
+
1589
+ const root = ReactNoop.createRoot();
1590
+ await act(() => {
1591
+ startTransition(() => {
1592
+ root.render(<App />);
1593
+ });
1594
+ });
1595
+ assertLog(['Async (function component)', 'Async (memo component)']);
1596
+ expect(root).toMatchRenderedOutput(
1597
+ <>
1598
+ <div>Async (function component)</div>
1599
+ <div>Async (memo component)</div>
1600
+ </>,
1601
+ );
1602
+ });
1603
});