Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10884,4 +10884,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
</html>,
);
});

// @gate enableCPUSuspense
it('outlines deferred Suspense boundaries', async () => {
function Log({text}) {
Scheduler.log(text);
return text;
}

await act(async () => {
renderToPipeableStream(
<div>
<Suspense defer={true} fallback={<Log text="Waiting" />}>
<span>{<Log text="hello" />}</span>
</Suspense>
</div>,
).pipe(writable);
await jest.runAllTimers();
const temp = document.createElement('body');
temp.innerHTML = buffer;
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
});

assertLog(['Waiting', 'hello']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>hello</span>
</div>,
);
});
});
15 changes: 14 additions & 1 deletion packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ export let didWarnAboutReassigningProps: boolean;
let didWarnAboutRevealOrder;
let didWarnAboutTailOptions;
let didWarnAboutClassNameOnViewTransition;
let didWarnAboutExpectedLoadTime = false;

if (__DEV__) {
didWarnAboutBadClass = ({}: {[string]: boolean});
Expand Down Expand Up @@ -2459,8 +2460,20 @@ function updateSuspenseComponent(
return bailoutOffscreenComponent(null, primaryChildFragment);
} else if (
enableCPUSuspense &&
typeof nextProps.unstable_expectedLoadTime === 'number'
(typeof nextProps.unstable_expectedLoadTime === 'number' ||
nextProps.defer === true)
) {
if (__DEV__) {
if (typeof nextProps.unstable_expectedLoadTime === 'number') {
if (!didWarnAboutExpectedLoadTime) {
didWarnAboutExpectedLoadTime = true;
console.error(
'<Suspense unstable_expectedLoadTime={...}> is deprecated. ' +
'Use <Suspense defer={true}> instead.',
);
}
}
}
// 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.
Expand Down
67 changes: 53 additions & 14 deletions packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable react/jsx-boolean-value */

let React;
let ReactNoop;
let Scheduler;
Expand All @@ -11,6 +13,7 @@ let resolveText;
// let rejectText;

let assertLog;
let assertConsoleErrorDev;
let waitForPaint;

describe('ReactSuspenseWithNoopRenderer', () => {
Expand All @@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {

const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
waitForPaint = InternalTestUtils.waitForPaint;

textCache = new Map();
Expand Down Expand Up @@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}

// @gate enableCPUSuspense
it('skips CPU-bound trees on initial mount', async () => {
it('warns for the old name is used', async () => {
function App() {
return (
<>
<Text text="Outer" />
<div>
<Suspense
unstable_expectedLoadTime={2000}
unstable_expectedLoadTime={1000}
fallback={<Text text="Loading..." />}>
<Text text="Inner" />
</Suspense>
Expand All @@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
);
}

const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
await waitForPaint(['Outer', 'Loading...']);
assertConsoleErrorDev([
'<Suspense unstable_expectedLoadTime={...}> is deprecated. ' +
'Use <Suspense defer={true}> instead.' +
'\n in Suspense (at **)' +
'\n in App (at **)',
]);
expect(root).toMatchRenderedOutput(
<>
Outer
<div>Loading...</div>
</>,
);
});

// Inner contents finish in separate commit from outer
assertLog(['Inner']);
expect(root).toMatchRenderedOutput(
<>
Outer
<div>Inner</div>
</>,
);
});

// @gate enableCPUSuspense
it('skips CPU-bound trees on initial mount', async () => {
function App() {
return (
<>
<Text text="Outer" />
<div>
<Suspense defer fallback={<Text text="Loading..." />}>
<Text text="Inner" />
</Suspense>
</div>
</>
);
}

const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
Expand Down Expand Up @@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
<>
<Text text="Outer" />
<div>
<Suspense
unstable_expectedLoadTime={2000}
fallback={<Text text="Loading..." />}>
<Suspense defer fallback={<Text text="Loading..." />}>
<Text text={`Inner [${count}]`} />
</Suspense>
</div>
Expand Down Expand Up @@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
<>
<Text text="Outer" />
<div>
<Suspense
unstable_expectedLoadTime={2000}
fallback={<Text text="Loading..." />}>
<Suspense defer fallback={<Text text="Loading..." />}>
<AsyncText text="Inner" />
</Suspense>
</div>
Expand Down Expand Up @@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
<>
<Text text="A" />
<div>
<Suspense
unstable_expectedLoadTime={2000}
fallback={<Text text="Loading B..." />}>
<Suspense defer fallback={<Text text="Loading B..." />}>
<Text text="B" />
<div>
<Suspense
unstable_expectedLoadTime={2000}
fallback={<Text text="Loading C..." />}>
<Suspense defer fallback={<Text text="Loading C..." />}>
<Text text="C" />
</Suspense>
</div>
Expand Down
51 changes: 34 additions & 17 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ import {
enableViewTransition,
enableFizzBlockingRender,
enableAsyncDebugInfo,
enableCPUSuspense,
} from 'shared/ReactFeatureFlags';

import assign from 'shared/assign';
Expand Down Expand Up @@ -253,6 +254,7 @@ type SuspenseBoundary = {
row: null | SuspenseListRow, // the row that this boundary blocks from completing.
completedSegments: Array<Segment>, // completed but not yet flushed segments.
byteSize: number, // used to determine whether to inline children boundaries.
defer: boolean, // never inline deferred boundaries
fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
contentState: HoistableState,
fallbackState: HoistableState,
Expand Down Expand Up @@ -462,7 +464,9 @@ function isEligibleForOutlining(
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
// outlining.
return (
(boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) &&
(boundary.byteSize > 500 ||
hasSuspenseyContent(boundary.contentState) ||
boundary.defer) &&
// For boundaries that can possibly contribute to the preamble we don't want to outline
// them regardless of their size since the fallbacks should only be emitted if we've
// errored the boundary.
Expand Down Expand Up @@ -798,6 +802,7 @@ function createSuspenseBoundary(
fallbackAbortableTasks: Set<Task>,
contentPreamble: null | Preamble,
fallbackPreamble: null | Preamble,
defer: boolean,
): SuspenseBoundary {
const boundary: SuspenseBoundary = {
status: PENDING,
Expand All @@ -807,6 +812,7 @@ function createSuspenseBoundary(
row: row,
completedSegments: [],
byteSize: 0,
defer: defer,
fallbackAbortableTasks,
errorDigest: null,
contentState: createHoistableState(),
Expand Down Expand Up @@ -1315,6 +1321,7 @@ function renderSuspenseBoundary(
// in case it ends up generating a large subtree of content.
const fallback: ReactNodeList = props.fallback;
const content: ReactNodeList = props.children;
const defer: boolean = enableCPUSuspense && props.defer === true;

const fallbackAbortSet: Set<Task> = new Set();
let newBoundary: SuspenseBoundary;
Expand All @@ -1325,6 +1332,7 @@ function renderSuspenseBoundary(
fallbackAbortSet,
createPreambleState(),
createPreambleState(),
defer,
);
} else {
newBoundary = createSuspenseBoundary(
Expand All @@ -1333,6 +1341,7 @@ function renderSuspenseBoundary(
fallbackAbortSet,
null,
null,
defer,
);
}
if (request.trackedPostpones !== null) {
Expand Down Expand Up @@ -1368,29 +1377,32 @@ function renderSuspenseBoundary(
// no parent segment so there's nothing to wait on.
contentRootSegment.parentFlushed = true;

if (request.trackedPostpones !== null) {
const trackedPostpones = request.trackedPostpones;
if (trackedPostpones !== null || defer) {
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
// and schedule the content to render later. This is the opposite of what we do during a normal render
// where we try to skip rendering the fallback if the content itself can render synchronously

// Stash the original stack frame.
const suspenseComponentStack = task.componentStack;
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
// the content to render later. This is the opposite of what we do during a normal render
// where we try to skip rendering the fallback if the content itself can render synchronously
const trackedPostpones = request.trackedPostpones;

const fallbackKeyPath: KeyNode = [
keyPath[0],
'Suspense Fallback',
keyPath[2],
];
const fallbackReplayNode: ReplayNode = [
fallbackKeyPath[1],
fallbackKeyPath[2],
([]: Array<ReplayNode>),
null,
];
trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
// We are rendering the fallback before the boundary content so we keep track of
// the fallback replay node until we determine if the primary content suspends
newBoundary.trackedFallbackNode = fallbackReplayNode;
if (trackedPostpones !== null) {
const fallbackReplayNode: ReplayNode = [
fallbackKeyPath[1],
fallbackKeyPath[2],
([]: Array<ReplayNode>),
null,
];
trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
// We are rendering the fallback before the boundary content so we keep track of
// the fallback replay node until we determine if the primary content suspends
newBoundary.trackedFallbackNode = fallbackReplayNode;
}

task.blockedSegment = boundarySegment;
task.blockedPreamble = newBoundary.fallbackPreamble;
Expand Down Expand Up @@ -1639,6 +1651,7 @@ function replaySuspenseBoundary(

const content: ReactNodeList = props.children;
const fallback: ReactNodeList = props.fallback;
const defer: boolean = enableCPUSuspense && props.defer === true;

const fallbackAbortSet: Set<Task> = new Set();
let resumedBoundary: SuspenseBoundary;
Expand All @@ -1649,6 +1662,7 @@ function replaySuspenseBoundary(
fallbackAbortSet,
createPreambleState(),
createPreambleState(),
defer,
);
} else {
resumedBoundary = createSuspenseBoundary(
Expand All @@ -1657,6 +1671,7 @@ function replaySuspenseBoundary(
fallbackAbortSet,
null,
null,
defer,
);
}
resumedBoundary.parentFlushed = true;
Expand Down Expand Up @@ -4552,6 +4567,7 @@ function abortRemainingSuspenseBoundary(
new Set(),
null,
null,
false,
);
resumedBoundary.parentFlushed = true;
// We restore the same id of this boundary as was used during prerender.
Expand Down Expand Up @@ -5759,7 +5775,8 @@ function flushSegment(
!flushingPartialBoundaries &&
isEligibleForOutlining(request, boundary) &&
(flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
hasSuspenseyContent(boundary.contentState))
hasSuspenseyContent(boundary.contentState) ||
boundary.defer)
) {
// Inlining this boundary would make the current sequence being written too large
// and block the parent for too long. Instead, it will be emitted separately so that we
Expand Down
1 change: 1 addition & 0 deletions packages/shared/ReactTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ export type SuspenseProps = {

unstable_avoidThisFallback?: boolean,
unstable_expectedLoadTime?: number,
defer?: boolean,
name?: string,
};

Expand Down
Loading