diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 198d07b87727..99dd385674ee 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -109,6 +109,7 @@ import { SyncLane, OffscreenLane, DefaultHydrationLane, + SomeRetryLane, NoTimestamp, includesSomeLane, laneToLanes, @@ -1658,6 +1659,7 @@ function updateSuspenseOffscreenState( }; } +// TODO: Probably should inline this back function shouldRemainOnFallback( suspenseContext: SuspenseContext, current: null | Fiber, @@ -1790,9 +1792,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { } } + const nextPrimaryChildren = nextProps.children; + const nextFallbackChildren = nextProps.fallback; if (showFallback) { - const nextPrimaryChildren = nextProps.children; - const nextFallbackChildren = nextProps.fallback; const fallbackFragment = mountSuspenseFallbackChildren( workInProgress, nextPrimaryChildren, @@ -1805,8 +1807,36 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { ); workInProgress.memoizedState = SUSPENDED_MARKER; return fallbackFragment; + } else if (typeof nextProps.unstable_expectedLoadTime === 'number') { + // This is a CPU-bound tree. Skip this tree and show a placeholder to + // unblock the surrounding content. Then immediately retry after the + // initial commit. + const fallbackFragment = mountSuspenseFallbackChildren( + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes, + ); + const primaryChildFragment: Fiber = (workInProgress.child: any); + primaryChildFragment.memoizedState = mountSuspenseOffscreenState( + renderLanes, + ); + workInProgress.memoizedState = SUSPENDED_MARKER; + + // Since nothing actually suspended, there will nothing to ping this to + // get it started back up to attempt the next item. While in terms of + // priority this work has the same priority as this current render, it's + // not part of the same transition once the transition has committed. If + // it's sync, we still want to yield so that it can be painted. + // Conceptually, this is really the same as pinging. We can use any + // RetryLane even if it's the one currently rendering since we're leaving + // it behind on this node. + workInProgress.lanes = SomeRetryLane; + if (enableSchedulerTracing) { + markSpawnedWork(SomeRetryLane); + } + return fallbackFragment; } else { - const nextPrimaryChildren = nextProps.children; return mountSuspensePrimaryChildren( workInProgress, nextPrimaryChildren, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 23a97b297ce6..ef41dff89a0e 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -109,6 +109,7 @@ import { SyncLane, OffscreenLane, DefaultHydrationLane, + SomeRetryLane, NoTimestamp, includesSomeLane, laneToLanes, @@ -1657,6 +1658,7 @@ function updateSuspenseOffscreenState( }; } +// TODO: Probably should inline this back function shouldRemainOnFallback( suspenseContext: SuspenseContext, current: null | Fiber, @@ -1789,9 +1791,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { } } + const nextPrimaryChildren = nextProps.children; + const nextFallbackChildren = nextProps.fallback; if (showFallback) { - const nextPrimaryChildren = nextProps.children; - const nextFallbackChildren = nextProps.fallback; const fallbackFragment = mountSuspenseFallbackChildren( workInProgress, nextPrimaryChildren, @@ -1804,8 +1806,36 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { ); workInProgress.memoizedState = SUSPENDED_MARKER; return fallbackFragment; + } else if (typeof nextProps.unstable_expectedLoadTime === 'number') { + // This is a CPU-bound tree. Skip this tree and show a placeholder to + // unblock the surrounding content. Then immediately retry after the + // initial commit. + const fallbackFragment = mountSuspenseFallbackChildren( + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes, + ); + const primaryChildFragment: Fiber = (workInProgress.child: any); + primaryChildFragment.memoizedState = mountSuspenseOffscreenState( + renderLanes, + ); + workInProgress.memoizedState = SUSPENDED_MARKER; + + // Since nothing actually suspended, there will nothing to ping this to + // get it started back up to attempt the next item. While in terms of + // priority this work has the same priority as this current render, it's + // not part of the same transition once the transition has committed. If + // it's sync, we still want to yield so that it can be painted. + // Conceptually, this is really the same as pinging. We can use any + // RetryLane even if it's the one currently rendering since we're leaving + // it behind on this node. + workInProgress.lanes = SomeRetryLane; + if (enableSchedulerTracing) { + markSpawnedWork(SomeRetryLane); + } + return fallbackFragment; } else { - const nextPrimaryChildren = nextProps.children; return mountSuspensePrimaryChildren( workInProgress, nextPrimaryChildren, diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index 2f9d9f365a98..385a2ebdfa67 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane'; @@ -17,6 +18,16 @@ import { isSuspenseInstanceFallback, } from './ReactFiberHostConfig'; +export type SuspenseProps = {| + children?: ReactNodeList, + fallback?: ReactNodeList, + + // TODO: Add "unstable_" prefix? + suspenseCallback?: (Set | null) => mixed, + + unstable_expectedLoadTime?: number, +|}; + // A null SuspenseState represents an unsuspended normal Suspense boundary. // A non-null SuspenseState means that it is blocked for one reason or another. // - A non-null dehydrated field means it's blocked pending hydration. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index ee8f4c891892..6e14aacd77de 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane'; @@ -17,6 +18,16 @@ import { isSuspenseInstanceFallback, } from './ReactFiberHostConfig'; +export type SuspenseProps = {| + children?: ReactNodeList, + fallback?: ReactNodeList, + + // TODO: Add "unstable_" prefix? + suspenseCallback?: (Set | null) => mixed, + + unstable_expectedLoadTime?: number, +|}; + // A null SuspenseState represents an unsuspended normal Suspense boundary. // A non-null SuspenseState means that it is blocked for one reason or another. // - A non-null dehydrated field means it's blocked pending hydration. diff --git a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js new file mode 100644 index 000000000000..1de9050caa4a --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js @@ -0,0 +1,287 @@ +let React; +let ReactNoop; +let Scheduler; +let Suspense; +let useState; +let textCache; + +let readText; +let resolveText; +// let rejectText; + +describe('ReactSuspenseWithNoopRenderer', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + Suspense = React.Suspense; + useState = React.useState; + + textCache = new Map(); + + readText = text => { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.promise; + case 'rejected': + throw Error('Failed to load: ' + text); + case 'resolved': + return text; + } + } else { + let ping; + const promise = new Promise(resolve => (ping = resolve)); + const newRecord = { + status: 'pending', + ping: ping, + promise, + }; + textCache.set(text, newRecord); + throw promise; + } + }; + + resolveText = text => { + const record = textCache.get(text); + if (record !== undefined) { + if (record.status === 'pending') { + record.ping(); + record.ping = null; + record.status = 'resolved'; + record.promise = null; + } + } else { + const newRecord = { + ping: null, + status: 'resolved', + promise: null, + }; + textCache.set(text, newRecord); + } + }; + + // rejectText = text => { + // const record = textCache.get(text); + // if (record !== undefined) { + // if (record.status === 'pending') { + // Scheduler.unstable_yieldValue(`Promise rejected [${text}]`); + // record.ping(); + // record.status = 'rejected'; + // clearTimeout(record.promise._timer); + // record.promise = null; + // } + // } else { + // const newRecord = { + // ping: null, + // status: 'rejected', + // promise: null, + // }; + // textCache.set(text, newRecord); + // } + // }; + }); + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return props.text; + } + + function AsyncText(props) { + const text = props.text; + try { + readText(text); + Scheduler.unstable_yieldValue(text); + return text; + } catch (promise) { + if (typeof promise.then === 'function') { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + } else { + Scheduler.unstable_yieldValue(`Error! [${text}]`); + } + throw promise; + } + } + + it('skips CPU-bound trees on initial mount', async () => { + function App() { + return ( + <> + +
+ }> + + +
+ + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + expect(Scheduler).toFlushUntilNextPaint(['Outer', 'Loading...']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Loading...
+ , + ); + }); + // Inner contents finish in separate commit from outer + expect(Scheduler).toHaveYielded(['Inner']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Inner
+ , + ); + }); + + it('does not skip CPU-bound trees during updates', async () => { + let setCount; + + function App() { + const [count, _setCount] = useState(0); + setCount = _setCount; + return ( + <> + +
+ }> + + +
+ + ); + } + + // Initial mount + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + // Inner contents finish in separate commit from outer + expect(Scheduler).toHaveYielded(['Outer', 'Loading...', 'Inner [0]']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Inner [0]
+ , + ); + + // Update + await ReactNoop.act(async () => { + setCount(1); + }); + // Entire update finishes in a single commit + expect(Scheduler).toHaveYielded(['Outer', 'Inner [1]']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Inner [1]
+ , + ); + }); + + it('suspend inside CPU-bound tree', async () => { + function App() { + return ( + <> + +
+ }> + + +
+ + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + expect(Scheduler).toFlushUntilNextPaint(['Outer', 'Loading...']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Loading...
+ , + ); + }); + // Inner contents suspended, so we continue showing a fallback. + expect(Scheduler).toHaveYielded(['Suspend! [Inner]']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Loading...
+ , + ); + + // Resolve the data and finish rendering + await ReactNoop.act(async () => { + await resolveText('Inner'); + }); + expect(Scheduler).toHaveYielded(['Inner']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Inner
+ , + ); + }); + + it('nested CPU-bound trees', async () => { + function App() { + return ( + <> + +
+ }> + +
+ }> + + +
+
+
+ + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + // Each level commits separately + expect(Scheduler).toHaveYielded([ + 'A', + 'Loading B...', + 'B', + 'Loading C...', + 'C', + ]); + expect(root).toMatchRenderedOutput( + <> + A +
+ B
C
+
+ , + ); + }); +});