Skip to content

Commit 5bbb04e

Browse files
committed
[Fizz][Float] Do not write after closing the stream (facebook#27541)
Float methods can hang on to a reference to a Request after the request is closed due to AsyncLocalStorage. If a Float method is called at this point we do not want to attempt to flush anything. This change updates the closing logic to also call `stopFlowing` which will ensure that any checks against the destination properly reflect that we cannot do any writes. In addition it updates the enqueueFlush logic to existence check the destination inside the work function since it can change across the work scheduling gap if it is async. fixes: facebook#27540
1 parent 720de7f commit 5bbb04e

File tree

3 files changed

+88
-2
lines changed

3 files changed

+88
-2
lines changed

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3624,6 +3624,46 @@ describe('ReactDOMFizzServer', () => {
36243624
);
36253625
});
36263626

3627+
// https://github.com/facebook/react/issues/27540
3628+
// This test is not actually asserting much because there is possibly a bug in the closeing logic for the
3629+
// Node implementation of Fizz. The close leads to an abort which sets the destination to null before the Float
3630+
// method has an opportunity to schedule a write. We should fix this probably and once we do this test will start
3631+
// to fail if the underyling issue of writing after stream completion isn't fixed
3632+
it('does not try to write to the stream after it has been closed', async () => {
3633+
async function preloadLate() {
3634+
await 1;
3635+
ReactDOM.preconnect('foo');
3636+
}
3637+
3638+
function Preload() {
3639+
preloadLate();
3640+
return null;
3641+
}
3642+
3643+
function App() {
3644+
return (
3645+
<html>
3646+
<body>
3647+
<main>hello</main>
3648+
<Preload />
3649+
</body>
3650+
</html>
3651+
);
3652+
}
3653+
await act(() => {
3654+
renderToPipeableStream(<App />).pipe(writable);
3655+
});
3656+
3657+
expect(getVisibleChildren(document)).toEqual(
3658+
<html>
3659+
<head />
3660+
<body>
3661+
<main>hello</main>
3662+
</body>
3663+
</html>,
3664+
);
3665+
});
3666+
36273667
describe('error escaping', () => {
36283668
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
36293669
window.__outlet = {};

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ global.ReadableStream =
1515
global.TextEncoder = require('util').TextEncoder;
1616

1717
let React;
18+
let ReactDOM;
1819
let ReactDOMFizzServer;
1920
let Suspense;
2021

2122
describe('ReactDOMFizzServerBrowser', () => {
2223
beforeEach(() => {
2324
jest.resetModules();
2425
React = require('react');
26+
ReactDOM = require('react-dom');
2527
ReactDOMFizzServer = require('react-dom/server.browser');
2628
Suspense = React.Suspense;
2729
});
@@ -547,4 +549,37 @@ describe('ReactDOMFizzServerBrowser', () => {
547549
// However, it does error the shell.
548550
expect(caughtError.message).toEqual('testing postpone');
549551
});
552+
553+
// https://github.com/facebook/react/issues/27540
554+
// This test is not actually asserting much because in our test environment the Float method cannot find the request after
555+
// an await and thus is a noop. If we fix our test environment to support AsyncLocalStorage we can assert that the
556+
// stream does not write after closing.
557+
it('does not try to write to the stream after it has been closed', async () => {
558+
async function preloadLate() {
559+
await 1;
560+
ReactDOM.preconnect('foo');
561+
}
562+
563+
function Preload() {
564+
preloadLate();
565+
return null;
566+
}
567+
568+
function App() {
569+
return (
570+
<html>
571+
<body>
572+
<main>hello</main>
573+
<Preload />
574+
</body>
575+
</html>
576+
);
577+
}
578+
const stream = await ReactDOMFizzServer.renderToReadableStream(<App />);
579+
const result = await readResult(stream);
580+
581+
expect(result).toMatchInlineSnapshot(
582+
`"<!DOCTYPE html><html><head></head><body><main>hello</main></body></html>"`,
583+
);
584+
});
550585
});

packages/react-server/src/ReactFizzServer.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4041,6 +4041,9 @@ function flushCompletedQueues(
40414041
}
40424042
// We're done.
40434043
close(destination);
4044+
// We need to stop flowing now because we do not want any async contexts which might call
4045+
// float methods to initiate any flushes after this point
4046+
stopFlowing(request);
40444047
} else {
40454048
completeWriting(destination);
40464049
flushBuffered(destination);
@@ -4066,9 +4069,17 @@ function enqueueFlush(request: Request): void {
40664069
// happen when we start flowing again
40674070
request.destination !== null
40684071
) {
4069-
const destination = request.destination;
40704072
request.flushScheduled = true;
4071-
scheduleWork(() => flushCompletedQueues(request, destination));
4073+
scheduleWork(() => {
4074+
// We need to existence check destination again here because it might go away
4075+
// in between the enqueueFlush call and the work execution
4076+
const destination = request.destination;
4077+
if (destination) {
4078+
flushCompletedQueues(request, destination);
4079+
} else {
4080+
request.flushScheduled = false;
4081+
}
4082+
});
40724083
}
40734084
}
40744085

0 commit comments

Comments
 (0)