Skip to content

Commit

Permalink
[Flight] Allow aborting during render
Browse files Browse the repository at this point in the history
Previously if you aborted during a render the currently rendering task would itself be aborted which will cause the entire model to be replaced by the aborted error rather than just the slot currently being rendered.

This change updates the abort logic to mark currently rendering tasks as aborted but allowing the current render to emit a partially serialized model with an error reference in place of the current model.

The intent is to support aborting from rendering synchronously, in microtasks (after an await or in a .then) and in lazy initializers. We don't specifically support aborting from things like proxies that might be triggered during serialization of props
  • Loading branch information
gnoff committed Jun 5, 2024
1 parent c865358 commit e7be19d
Show file tree
Hide file tree
Showing 3 changed files with 429 additions and 21 deletions.
323 changes: 309 additions & 14 deletions packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,38 @@ describe('ReactFlightDOM', () => {
return maybePromise;
}

async function readInto(
container: Document | HTMLElement,
stream: ReadableStream,
) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let content = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
content += decoder.decode();
break;
}
content += decoder.decode(value, {stream: true});
}
if (container.nodeType === 9 /* DOCUMENT */) {
const doc = new JSDOM(content).window.document;
container.documentElement.innerHTML = doc.documentElement.innerHTML;
while (container.documentElement.attributes.length > 0) {
container.documentElement.removeAttribute(
container.documentElement.attributes[0].name,
);
}
const attrs = doc.documentElement.attributes;
for (let i = 0; i < attrs.length; i++) {
container.documentElement.setAttribute(attrs[i].name, attrs[i].value);
}
} else {
container.innerHTML = content;
}
}

function getTestStream() {
const writable = new Stream.PassThrough();
const readable = new ReadableStream({
Expand Down Expand Up @@ -1633,20 +1665,8 @@ describe('ReactFlightDOM', () => {
ReactDOMFizzServer.renderToPipeableStream(<App />).pipe(fizzWritable);
});

const decoder = new TextDecoder();
const reader = fizzReadable.getReader();
let content = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
content += decoder.decode();
break;
}
content += decoder.decode(value, {stream: true});
}

const doc = new JSDOM(content).window.document;
expect(getMeaningfulChildren(doc)).toEqual(
await readInto(document, fizzReadable);
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="dns-prefetch" href="d before" />
Expand Down Expand Up @@ -1912,4 +1932,279 @@ describe('ReactFlightDOM', () => {
});
expect(container.innerHTML).toBe('Hello World');
});

it('can abort synchronously during render', async () => {
let siblingDidRender = false;
function Sibling() {
siblingDidRender = true;
return <p>sibling</p>;
}

function App() {
return (
<Suspense fallback={<p>loading...</p>}>
<ComponentThatAborts />
<Sibling />
<div>
<Sibling />
</div>
</Suspense>
);
}

const abortRef = {current: null};
function ComponentThatAborts() {
abortRef.current();
return <p>hello world</p>;
}

const {writable: flightWritable, readable: flightReadable} =
getTestStream();

await serverAct(() => {
const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
<App />,
webpackMap,
);
abortRef.current = abort;
pipe(flightWritable);
});

expect(siblingDidRender).toBe(false);

const response =
ReactServerDOMClient.createFromReadableStream(flightReadable);

const {writable: fizzWritable, readable: fizzReadable} = getTestStream();

function ClientApp() {
return use(response);
}

const shellErrors = [];
await serverAct(async () => {
ReactDOMFizzServer.renderToPipeableStream(
React.createElement(ClientApp),
{
onShellError(error) {
shellErrors.push(error.message);
},
},
).pipe(fizzWritable);
});

expect(shellErrors).toEqual([]);

const container = document.createElement('div');
await readInto(container, fizzReadable);
expect(getMeaningfulChildren(container)).toEqual(<p>loading...</p>);
});

it('can abort during render in an async tick', async () => {
let siblingDidRender = false;
function DidRender({children}) {
siblingDidRender = true;
}

async function Sibling() {
return (
<DidRender>
<p>sibling</p>
</DidRender>
);
}

function App() {
return (
<Suspense fallback={<p>loading...</p>}>
<ComponentThatAborts />
<Sibling />
</Suspense>
);
}

const abortRef = {current: null};
async function ComponentThatAborts() {
await 1;
abortRef.current();
return <p>hello world</p>;
}

const {writable: flightWritable, readable: flightReadable} =
getTestStream();

await serverAct(() => {
const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
<App />,
webpackMap,
);
abortRef.current = abort;
pipe(flightWritable);
});

expect(siblingDidRender).toBe(false);

const response =
ReactServerDOMClient.createFromReadableStream(flightReadable);

const {writable: fizzWritable, readable: fizzReadable} = getTestStream();

function ClientApp() {
return use(response);
}

const shellErrors = [];
await serverAct(async () => {
ReactDOMFizzServer.renderToPipeableStream(
React.createElement(ClientApp),
{
onShellError(error) {
shellErrors.push(error.message);
},
},
).pipe(fizzWritable);
});

expect(shellErrors).toEqual([]);

const container = document.createElement('div');
await readInto(container, fizzReadable);
expect(getMeaningfulChildren(container)).toEqual(<p>loading...</p>);
});

it('can abort during render in a lazy initializer for a component', async () => {
let siblingDidRender = false;

function Sibling() {
siblingDidRender = true;
return <p>sibling</p>;
}

function App() {
return (
<Suspense fallback={<p>loading...</p>}>
<LazyAbort />
<Sibling />
</Suspense>
);
}

const abortRef = {current: null};
const LazyAbort = React.lazy(() => {
abortRef.current();
return Promise.resolve({
default: function LazyComponent() {
return <p>hello world</p>;
},
});
});

const {writable: flightWritable, readable: flightReadable} =
getTestStream();

await serverAct(() => {
const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
<App />,
webpackMap,
);
abortRef.current = abort;
pipe(flightWritable);
});

expect(siblingDidRender).toBe(false);

const response =
ReactServerDOMClient.createFromReadableStream(flightReadable);

const {writable: fizzWritable, readable: fizzReadable} = getTestStream();

function ClientApp() {
return use(response);
}

const shellErrors = [];
await serverAct(async () => {
ReactDOMFizzServer.renderToPipeableStream(
React.createElement(ClientApp),
{
onShellError(error) {
shellErrors.push(error.message);
},
},
).pipe(fizzWritable);
});

expect(shellErrors).toEqual([]);

const container = document.createElement('div');
await readInto(container, fizzReadable);
expect(getMeaningfulChildren(container)).toEqual(<p>loading...</p>);
});

it('can abort during render in a lazy initializer for an element', async () => {
let siblingDidRender = false;

function Sibling() {
siblingDidRender = true;
return <p>sibling</p>;
}

function App() {
return (
<Suspense fallback={<p>loading...</p>}>
{lazyAbort}
<Sibling />
</Suspense>
);
}

const abortRef = {current: null};
const lazyAbort = React.lazy(() => {
abortRef.current();
return Promise.resolve({
default: <p>hello world</p>,
});
});

const {writable: flightWritable, readable: flightReadable} =
getTestStream();

await serverAct(() => {
const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
<App />,
webpackMap,
);
abortRef.current = abort;
pipe(flightWritable);
});

expect(siblingDidRender).toBe(false);

const response =
ReactServerDOMClient.createFromReadableStream(flightReadable);

const {writable: fizzWritable, readable: fizzReadable} = getTestStream();

function ClientApp() {
return use(response);
}

const shellErrors = [];
await serverAct(async () => {
ReactDOMFizzServer.renderToPipeableStream(
React.createElement(ClientApp),
{
onShellError(error) {
shellErrors.push(error.message);
},
},
).pipe(fizzWritable);
});

expect(shellErrors).toEqual([]);

const container = document.createElement('div');
await readInto(container, fizzReadable);
expect(getMeaningfulChildren(container)).toEqual(<p>loading...</p>);
});
});
Loading

0 comments on commit e7be19d

Please sign in to comment.