Skip to content

Commit bff6be8

Browse files
authored
[Fizz] Track postpones in fallbacks (#27421)
This fixes so that you can postpone in a fallback. This postpones the parent boundary. I track the fallbacks in a separate replay node so that when we resume, we can replay the fallback itself and finish the fallback and then possibly later the content. By doing this we also ensure we don't complete the parent too early since now it has a render task on it. There is one case that this surfaces that isn't limited to prerender/resume but also render/hydrateRoot. I left todos in the tests for this. If you postpone in a fallback, and suspend in the content but eventually don't postpone in the content then we should be able to just skip postponing since the content rendered and we no longer need the fallback. This is a bit of a weird edge case though since fallbacks are supposed to be very minimal. This happens because in both cases the fallback starts rendering early as soon as the content suspends. This also ensures that the parent doesn't complete early by increasing the blocking tasks. Unfortunately, the fallback will irreversibly postpone its parent boundary as soon as it hits a postpone. When you suspend, the same thing happens but we typically deal with this by doing a "soft" abort on the fallback since we don't need it anymore which unblocks the parent boundary. We can't do that with postpone right now though since it's considered a terminal state. I think I'll just leave this as is for now since it's an edge case but it's an annoying exception in the model. Makes me feel I haven't quite nailed it just yet.
1 parent 94d5b5b commit bff6be8

File tree

3 files changed

+380
-53
lines changed

3 files changed

+380
-53
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6261,6 +6261,59 @@ describe('ReactDOMFizzServer', () => {
62616261
expect(fatalErrors).toEqual(['testing postpone']);
62626262
});
62636263

6264+
// @gate enablePostpone
6265+
it('can postpone in a fallback', async () => {
6266+
function Postponed({isClient}) {
6267+
if (!isClient) {
6268+
React.unstable_postpone('testing postpone');
6269+
}
6270+
return 'loading...';
6271+
}
6272+
6273+
const lazyText = React.lazy(async () => {
6274+
await 0; // causes the fallback to start work
6275+
return {default: 'Hello'};
6276+
});
6277+
6278+
function App({isClient}) {
6279+
return (
6280+
<div>
6281+
<Suspense fallback="Outer">
6282+
<Suspense fallback={<Postponed isClient={isClient} />}>
6283+
{lazyText}
6284+
</Suspense>
6285+
</Suspense>
6286+
</div>
6287+
);
6288+
}
6289+
6290+
const errors = [];
6291+
6292+
await act(() => {
6293+
const {pipe} = renderToPipeableStream(<App isClient={false} />, {
6294+
onError(error) {
6295+
errors.push(error.message);
6296+
},
6297+
});
6298+
pipe(writable);
6299+
});
6300+
6301+
// TODO: This should actually be fully resolved because the value could eventually
6302+
// resolve on the server even though the fallback couldn't so we should have been
6303+
// able to render it.
6304+
expect(getVisibleChildren(container)).toEqual(<div>Outer</div>);
6305+
6306+
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
6307+
onRecoverableError(error) {
6308+
errors.push(error.message);
6309+
},
6310+
});
6311+
await waitForAll([]);
6312+
// Postponing should not be logged as a recoverable error since it's intentional.
6313+
expect(errors).toEqual([]);
6314+
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
6315+
});
6316+
62646317
it(
62656318
'a transition that flows into a dehydrated boundary should not suspend ' +
62666319
'if the boundary is showing a fallback',
@@ -6830,4 +6883,94 @@ describe('ReactDOMFizzServer', () => {
68306883
],
68316884
);
68326885
});
6886+
6887+
// @gate enablePostpone
6888+
it('can postpone in fallback', async () => {
6889+
let prerendering = true;
6890+
function Postpone() {
6891+
if (prerendering) {
6892+
React.unstable_postpone();
6893+
}
6894+
return 'Hello';
6895+
}
6896+
6897+
let resolve;
6898+
const promise = new Promise(r => (resolve = r));
6899+
6900+
function PostponeAndDelay() {
6901+
if (prerendering) {
6902+
React.unstable_postpone();
6903+
}
6904+
return React.use(promise);
6905+
}
6906+
6907+
const Lazy = React.lazy(async () => {
6908+
await 0;
6909+
return {default: Postpone};
6910+
});
6911+
6912+
function App() {
6913+
return (
6914+
<div>
6915+
<Suspense fallback="Outer">
6916+
<Suspense fallback={<Postpone />}>
6917+
<PostponeAndDelay /> World
6918+
</Suspense>
6919+
<Suspense fallback={<Postpone />}>
6920+
<Lazy />
6921+
</Suspense>
6922+
</Suspense>
6923+
</div>
6924+
);
6925+
}
6926+
6927+
const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
6928+
expect(prerendered.postponed).not.toBe(null);
6929+
6930+
prerendering = false;
6931+
6932+
// Create a separate stream so it doesn't close the writable. I.e. simple concat.
6933+
const preludeWritable = new Stream.PassThrough();
6934+
preludeWritable.setEncoding('utf8');
6935+
preludeWritable.on('data', chunk => {
6936+
writable.write(chunk);
6937+
});
6938+
6939+
await act(() => {
6940+
prerendered.prelude.pipe(preludeWritable);
6941+
});
6942+
6943+
const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
6944+
<App />,
6945+
JSON.parse(JSON.stringify(prerendered.postponed)),
6946+
);
6947+
6948+
expect(getVisibleChildren(container)).toEqual(<div>Outer</div>);
6949+
6950+
// Read what we've completed so far
6951+
await act(() => {
6952+
resumed.pipe(writable);
6953+
});
6954+
6955+
// Should have now resolved the postponed loading state, but not the promise
6956+
expect(getVisibleChildren(container)).toEqual(
6957+
<div>
6958+
{'Hello'}
6959+
{'Hello'}
6960+
</div>,
6961+
);
6962+
6963+
// Resolve the final promise
6964+
await act(() => {
6965+
resolve('Hi');
6966+
});
6967+
6968+
expect(getVisibleChildren(container)).toEqual(
6969+
<div>
6970+
{'Hi'}
6971+
{' World'}
6972+
{'Hello'}
6973+
</div>,
6974+
);
6975+
});
68336976
});

packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,8 +870,6 @@ describe('ReactDOMFizzStaticBrowser', () => {
870870

871871
prerendering = false;
872872

873-
console.log(JSON.stringify(prerendered.postponed, null, 2));
874-
875873
const resumed = await ReactDOMFizzServer.resume(
876874
<App />,
877875
JSON.parse(JSON.stringify(prerendered.postponed)),
@@ -887,4 +885,100 @@ describe('ReactDOMFizzStaticBrowser', () => {
887885
<div>{['Hello', 'Hello', 'Hello']}</div>,
888886
);
889887
});
888+
889+
// @gate enablePostpone
890+
it('can postpone in fallback', async () => {
891+
let prerendering = true;
892+
function Postpone() {
893+
if (prerendering) {
894+
React.unstable_postpone();
895+
}
896+
return 'Hello';
897+
}
898+
899+
const Lazy = React.lazy(async () => {
900+
await 0;
901+
return {default: Postpone};
902+
});
903+
904+
function App() {
905+
return (
906+
<div>
907+
<Suspense fallback="Outer">
908+
<Suspense fallback={<Postpone />}>
909+
<Postpone /> World
910+
</Suspense>
911+
<Suspense fallback={<Postpone />}>
912+
<Lazy />
913+
</Suspense>
914+
</Suspense>
915+
</div>
916+
);
917+
}
918+
919+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
920+
expect(prerendered.postponed).not.toBe(null);
921+
922+
prerendering = false;
923+
924+
const resumed = await ReactDOMFizzServer.resume(
925+
<App />,
926+
JSON.parse(JSON.stringify(prerendered.postponed)),
927+
);
928+
929+
await readIntoContainer(prerendered.prelude);
930+
931+
expect(getVisibleChildren(container)).toEqual(<div>Outer</div>);
932+
933+
await readIntoContainer(resumed);
934+
935+
expect(getVisibleChildren(container)).toEqual(
936+
<div>
937+
{'Hello'}
938+
{' World'}
939+
{'Hello'}
940+
</div>,
941+
);
942+
});
943+
944+
// @gate enablePostpone
945+
it('can postpone in fallback without postponing the tree', async () => {
946+
function Postpone() {
947+
React.unstable_postpone();
948+
}
949+
950+
const lazyText = React.lazy(async () => {
951+
await 0; // causes the fallback to start work
952+
return {default: 'Hello'};
953+
});
954+
955+
function App() {
956+
return (
957+
<div>
958+
<Suspense fallback="Outer">
959+
<Suspense fallback={<Postpone />}>{lazyText}</Suspense>
960+
</Suspense>
961+
</div>
962+
);
963+
}
964+
965+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
966+
// TODO: This should actually be null because we should've been able to fully
967+
// resolve the render on the server eventually, even though the fallback postponed.
968+
// So we should not need to resume.
969+
expect(prerendered.postponed).not.toBe(null);
970+
971+
await readIntoContainer(prerendered.prelude);
972+
973+
expect(getVisibleChildren(container)).toEqual(<div>Outer</div>);
974+
975+
const resumed = await ReactDOMFizzServer.resume(
976+
<App />,
977+
JSON.parse(JSON.stringify(prerendered.postponed)),
978+
);
979+
980+
await readIntoContainer(resumed);
981+
982+
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
983+
});
890984
});

0 commit comments

Comments
 (0)