Skip to content

Commit

Permalink
Re-add reentrancy avoidance (#23342)
Browse files Browse the repository at this point in the history
* tests: add failing test to demonstrate bug in ReadableStream implementation

* Re-add reentrancy avoidance

I removed the equivalency of this in #22446. However, I didn't fully
understand the intended semantics of the spec but I understand this better
now.

The spec is not actually recursive. It won't call pull again inside of a
pull. It might not call it inside startWork neither which the original
issue avoided. However, it will call pull if you enqueue to the controller
without filling up the desired size outside any call.

We could avoid that by returning a Promise from pull that we wait to
resolve until we've performed all our pending tasks. That would be the
more idiomatic solution. That's a bit more involved but since we know
understand it, we can readd the reentrancy hack since we have an easy place
to detect it. If anything, it should probably throw or log here otherwise.

I believe this fixes #22772.

This includes the test from #22889 but should ideally have one for Fizz.

Co-authored-by: Josh Larson <josh.larson@shopify.com>
  • Loading branch information
sebmarkbage and jplhomer authored Feb 23, 2022
1 parent 1760b27 commit 2c693b2
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ describe('ReactFlightDOMBrowser', () => {
}
}

function makeDelayedText(Model) {
let error, _resolve, _reject;
let promise = new Promise((resolve, reject) => {
_resolve = () => {
promise = null;
resolve();
};
_reject = e => {
error = e;
promise = null;
reject(e);
};
});
function DelayedText({children}, data) {
if (promise) {
throw promise;
}
if (error) {
throw error;
}
return <Model>{children}</Model>;
}
return [DelayedText, _resolve, _reject];
}

it('should resolve HTML using W3C streams', async () => {
function Text({children}) {
return <span>{children}</span>;
Expand Down Expand Up @@ -174,36 +199,11 @@ describe('ReactFlightDOMBrowser', () => {
return children;
}

function makeDelayedText() {
let error, _resolve, _reject;
let promise = new Promise((resolve, reject) => {
_resolve = () => {
promise = null;
resolve();
};
_reject = e => {
error = e;
promise = null;
reject(e);
};
});
function DelayedText({children}, data) {
if (promise) {
throw promise;
}
if (error) {
throw error;
}
return <Text>{children}</Text>;
}
return [DelayedText, _resolve, _reject];
}

const [Friends, resolveFriends] = makeDelayedText();
const [Name, resolveName] = makeDelayedText();
const [Posts, resolvePosts] = makeDelayedText();
const [Photos, resolvePhotos] = makeDelayedText();
const [Games, , rejectGames] = makeDelayedText();
const [Friends, resolveFriends] = makeDelayedText(Text);
const [Name, resolveName] = makeDelayedText(Text);
const [Posts, resolvePosts] = makeDelayedText(Text);
const [Photos, resolvePhotos] = makeDelayedText(Text);
const [Games, , rejectGames] = makeDelayedText(Text);

// View
function ProfileDetails({avatar}) {
Expand Down Expand Up @@ -340,4 +340,117 @@ describe('ReactFlightDOMBrowser', () => {

expect(reportedErrors).toEqual([]);
});

it('should close the stream upon completion when rendering to W3C streams', async () => {
const {Suspense} = React;

// Model
function Text({children}) {
return children;
}

const [Friends, resolveFriends] = makeDelayedText(Text);
const [Name, resolveName] = makeDelayedText(Text);
const [Posts, resolvePosts] = makeDelayedText(Text);
const [Photos, resolvePhotos] = makeDelayedText(Text);

// View
function ProfileDetails({avatar}) {
return (
<div>
<Name>:name:</Name>
{avatar}
</div>
);
}
function ProfileSidebar({friends}) {
return (
<div>
<Photos>:photos:</Photos>
{friends}
</div>
);
}
function ProfilePosts({posts}) {
return <div>{posts}</div>;
}

function ProfileContent() {
return (
<Suspense fallback="(loading everything)">
<ProfileDetails avatar={<Text>:avatar:</Text>} />
<Suspense fallback={<p>(loading sidebar)</p>}>
<ProfileSidebar friends={<Friends>:friends:</Friends>} />
</Suspense>
<Suspense fallback={<p>(loading posts)</p>}>
<ProfilePosts posts={<Posts>:posts:</Posts>} />
</Suspense>
</Suspense>
);
}

const model = {
rootContent: <ProfileContent />,
};

const stream = ReactServerDOMWriter.renderToReadableStream(
model,
webpackMap,
);

const reader = stream.getReader();
const decoder = new TextDecoder();

let flightResponse = '';
let isDone = false;

reader.read().then(function progress({done, value}) {
if (done) {
isDone = true;
return;
}

flightResponse += decoder.decode(value);

return reader.read().then(progress);
});

// Advance time enough to trigger a nested fallback.
jest.advanceTimersByTime(500);

await act(async () => {});

expect(flightResponse).toContain('(loading everything)');
expect(flightResponse).toContain('(loading sidebar)');
expect(flightResponse).toContain('(loading posts)');
expect(flightResponse).not.toContain(':friends:');
expect(flightResponse).not.toContain(':name:');

await act(async () => {
resolveFriends();
});

expect(flightResponse).toContain(':friends:');

await act(async () => {
resolveName();
});

expect(flightResponse).toContain(':name:');

await act(async () => {
resolvePhotos();
});

expect(flightResponse).toContain(':photos:');

await act(async () => {
resolvePosts();
});

expect(flightResponse).toContain(':posts:');

// Final pending chunk is written; stream should be closed.
expect(isDone).toBeTruthy();
});
});
4 changes: 4 additions & 0 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1950,6 +1950,10 @@ export function startFlowing(request: Request, destination: Destination): void {
if (request.status === CLOSED) {
return;
}
if (request.destination !== null) {
// We're already flowing.
return;
}
request.destination = destination;
try {
flushCompletedQueues(request, destination);
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,10 @@ export function startFlowing(request: Request, destination: Destination): void {
if (request.status === CLOSED) {
return;
}
if (request.destination !== null) {
// We're already flowing.
return;
}
request.destination = destination;
try {
flushCompletedChunks(request, destination);
Expand Down

0 comments on commit 2c693b2

Please sign in to comment.