Skip to content

Commit

Permalink
Capture suspense boundaries with undefined fallbacks (facebook#21854)
Browse files Browse the repository at this point in the history
  • Loading branch information
rickhanlonii authored and zhengjitf committed Apr 15, 2022
1 parent 9eba392 commit 5aba26b
Show file tree
Hide file tree
Showing 14 changed files with 452 additions and 129 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ describe('ReactDOMServerPartialHydration', () => {
expect(container.textContent).toBe('Hi');
});

it('shows the fallback of the outer if fallback is missing', async () => {
it('treats missing fallback the same as if it was defined', async () => {
// This is the same exact test as above but with a nested Suspense without a fallback.
// This should be a noop.
let suspend = false;
Expand Down Expand Up @@ -759,7 +759,8 @@ describe('ReactDOMServerPartialHydration', () => {
Scheduler.unstable_flushAll();
jest.runAllTimers();

expect(ref.current).toBe(null);
const span = container.getElementsByTagName('span')[0];
expect(ref.current).toBe(span);

// Render an update, but leave it still suspended.
root.render(<App text="Hi" className="hi" />);
Expand All @@ -768,9 +769,9 @@ describe('ReactDOMServerPartialHydration', () => {
Scheduler.unstable_flushAll();
jest.runAllTimers();

expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
expect(container.getElementsByTagName('span').length).toBe(1);
expect(ref.current).toBe(span);
expect(container.textContent).toBe('');

// Unsuspending shows the content.
suspend = false;
Expand All @@ -780,7 +781,6 @@ describe('ReactDOMServerPartialHydration', () => {
Scheduler.unstable_flushAll();
jest.runAllTimers();

const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,12 @@ describe('ReactDOMServerHydration', () => {
element,
);

// Because this didn't have a fallback, it was hydrated as if it's
// not a Suspense boundary.
expect(ref.current).toBe(div);
expect(element.innerHTML).toBe('<div>Hello World</div>');
// The content should've been client rendered.
expect(ref.current).not.toBe(div);
// Unfortunately, since we don't delete the tail at the root, a duplicate will remain.
expect(element.innerHTML).toBe(
'<div>Hello World</div><div>Hello World</div>',
);
});

// regression test for https://github.com/facebook/react/issues/17170
Expand Down
19 changes: 0 additions & 19 deletions packages/react-dom/src/server/ReactPartialRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1123,25 +1123,6 @@ class ReactDOMServerRenderer {
case REACT_SUSPENSE_TYPE: {
if (enableSuspenseServerRenderer) {
const fallback = ((nextChild: any): ReactElement).props.fallback;
if (fallback === undefined) {
// If there is no fallback, then this just behaves as a fragment.
const nextChildren = toArray(
((nextChild: any): ReactElement).props.children,
);
const frame: Frame = {
type: null,
domNamespace: parentNamespace,
children: nextChildren,
childIndex: 0,
context: context,
footer: '',
};
if (__DEV__) {
((frame: any): FrameDev).debugElementStack = [];
}
this.stack.push(frame);
return '';
}
const fallbackChildren = toArray(fallback);
const nextChildren = toArray(
((nextChild: any): ReactElement).props.children,
Expand Down
36 changes: 14 additions & 22 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
// This is a new mount or this boundary is already showing a fallback state.
// Mark this subtree context as having at least one invisible parent that could
// handle the fallback state.
// Boundaries without fallbacks or should be avoided are not considered since
// they cannot handle preferred fallback states.
if (
nextProps.fallback !== undefined &&
nextProps.unstable_avoidThisFallback !== true
) {
// Avoided boundaries are not considered since they cannot handle preferred fallback states.
if (nextProps.unstable_avoidThisFallback !== true) {
suspenseContext = addSubtreeSuspenseContext(
suspenseContext,
InvisibleParentSuspenseContext,
Expand Down Expand Up @@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
if (current === null) {
// Initial mount
// If we're currently hydrating, try to hydrate this boundary.
// But only if this has a fallback.
if (nextProps.fallback !== undefined) {
tryToClaimNextHydratableInstance(workInProgress);
// This could've been a dehydrated suspense component.
if (enableSuspenseServerRenderer) {
const suspenseState: null | SuspenseState =
workInProgress.memoizedState;
if (suspenseState !== null) {
const dehydrated = suspenseState.dehydrated;
if (dehydrated !== null) {
return mountDehydratedSuspenseComponent(
workInProgress,
dehydrated,
renderLanes,
);
}
tryToClaimNextHydratableInstance(workInProgress);
// This could've been a dehydrated suspense component.
if (enableSuspenseServerRenderer) {
const suspenseState: null | SuspenseState = workInProgress.memoizedState;
if (suspenseState !== null) {
const dehydrated = suspenseState.dehydrated;
if (dehydrated !== null) {
return mountDehydratedSuspenseComponent(
workInProgress,
dehydrated,
renderLanes,
);
}
}
}
Expand Down
36 changes: 14 additions & 22 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
// This is a new mount or this boundary is already showing a fallback state.
// Mark this subtree context as having at least one invisible parent that could
// handle the fallback state.
// Boundaries without fallbacks or should be avoided are not considered since
// they cannot handle preferred fallback states.
if (
nextProps.fallback !== undefined &&
nextProps.unstable_avoidThisFallback !== true
) {
// Avoided boundaries are not considered since they cannot handle preferred fallback states.
if (nextProps.unstable_avoidThisFallback !== true) {
suspenseContext = addSubtreeSuspenseContext(
suspenseContext,
InvisibleParentSuspenseContext,
Expand Down Expand Up @@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
if (current === null) {
// Initial mount
// If we're currently hydrating, try to hydrate this boundary.
// But only if this has a fallback.
if (nextProps.fallback !== undefined) {
tryToClaimNextHydratableInstance(workInProgress);
// This could've been a dehydrated suspense component.
if (enableSuspenseServerRenderer) {
const suspenseState: null | SuspenseState =
workInProgress.memoizedState;
if (suspenseState !== null) {
const dehydrated = suspenseState.dehydrated;
if (dehydrated !== null) {
return mountDehydratedSuspenseComponent(
workInProgress,
dehydrated,
renderLanes,
);
}
tryToClaimNextHydratableInstance(workInProgress);
// This could've been a dehydrated suspense component.
if (enableSuspenseServerRenderer) {
const suspenseState: null | SuspenseState = workInProgress.memoizedState;
if (suspenseState !== null) {
const dehydrated = suspenseState.dehydrated;
if (dehydrated !== null) {
return mountDehydratedSuspenseComponent(
workInProgress,
dehydrated,
renderLanes,
);
}
}
}
Expand Down
4 changes: 1 addition & 3 deletions packages/react-reconciler/src/ReactFiberCompleteWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -1045,9 +1045,7 @@ function completeWork(
const nextDidTimeout = nextState !== null;
let prevDidTimeout = false;
if (current === null) {
if (workInProgress.memoizedProps.fallback !== undefined) {
popHydrationState(workInProgress);
}
popHydrationState(workInProgress);
} else {
const prevState: null | SuspenseState = current.memoizedState;
prevDidTimeout = prevState !== null;
Expand Down
4 changes: 1 addition & 3 deletions packages/react-reconciler/src/ReactFiberCompleteWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -1045,9 +1045,7 @@ function completeWork(
const nextDidTimeout = nextState !== null;
let prevDidTimeout = false;
if (current === null) {
if (workInProgress.memoizedProps.fallback !== undefined) {
popHydrationState(workInProgress);
}
popHydrationState(workInProgress);
} else {
const prevState: null | SuspenseState = current.memoizedState;
prevDidTimeout = prevState !== null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,6 @@ export function shouldCaptureSuspense(
return false;
}
const props = workInProgress.memoizedProps;
// In order to capture, the Suspense component must have a fallback prop.
if (props.fallback === undefined) {
return false;
}
// Regular boundaries always capture.
if (props.unstable_avoidThisFallback !== true) {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,6 @@ export function shouldCaptureSuspense(
return false;
}
const props = workInProgress.memoizedProps;
// In order to capture, the Suspense component must have a fallback prop.
if (props.fallback === undefined) {
return false;
}
// Regular boundaries always capture.
if (props.unstable_avoidThisFallback !== true) {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ let Scheduler;
let ReactDOMServer;
let act;

// Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to
// gradually migrate those to this file.
describe('ReactHooks', () => {
beforeEach(() => {
jest.resetModules();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ let act;
let TextResource;
let textResourceShouldFail;

// Additional tests can be found in ReactSuspenseWithNoopRenderer. Plan is
// to gradually migrate those to this file.
describe('ReactSuspense', () => {
beforeEach(() => {
jest.resetModules();
Expand Down Expand Up @@ -391,44 +389,10 @@ describe('ReactSuspense', () => {
expect(root).toMatchRenderedOutput('Hi');
});

it('only captures if `fallback` is defined', () => {
const root = ReactTestRenderer.create(
<Suspense fallback={<Text text="Loading..." />}>
<Suspense>
<AsyncText text="Hi" ms={5000} />
</Suspense>
</Suspense>,
{
unstable_isConcurrent: true,
},
);

expect(Scheduler).toFlushAndYield([
'Suspend! [Hi]',
// The outer fallback should be rendered, because the inner one does not
// have a `fallback` prop
'Loading...',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded([]);
expect(Scheduler).toFlushAndYield([]);
expect(root).toMatchRenderedOutput('Loading...');

jest.advanceTimersByTime(5000);
expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']);
expect(Scheduler).toFlushAndYield(['Hi']);
expect(root).toMatchRenderedOutput('Hi');
});

it('throws if tree suspends and none of the Suspense ancestors have a fallback', () => {
ReactTestRenderer.create(
<Suspense>
<AsyncText text="Hi" ms={1000} />
</Suspense>,
{
unstable_isConcurrent: true,
},
);
it('throws if tree suspends and none of the Suspense ancestors have a boundary', () => {
ReactTestRenderer.create(<AsyncText text="Hi" ms={1000} />, {
unstable_isConcurrent: true,
});

expect(Scheduler).toFlushAndThrow(
'AsyncText suspended while rendering, but no fallback UI was specified.',
Expand Down
Loading

0 comments on commit 5aba26b

Please sign in to comment.