From 08146f1bae09cc6358e0d724a982020ebdb6362f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 17 Mar 2021 20:54:29 -0400 Subject: [PATCH 1/5] Track all suspended work while it's still pending This allows us to abort work and put everything into client rendered mode if we don't want to wait for further I/O. It also allows us to cancel fallbacks if we complete the main content before the fallback. --- .../__tests__/ReactDOMFizzServerNode-test.js | 50 +++++++ .../src/server/ReactDOMFizzServerNode.js | 14 +- .../src/ReactNoopServer.js | 3 + packages/react-server/src/ReactFizzServer.js | 133 +++++++++++++++--- 4 files changed, 183 insertions(+), 17 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index 1b1c5c4a314ac..b5f6d0bfa83ad 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -115,4 +115,54 @@ describe('ReactDOMFizzServer', () => { expect(output.error).toBe(undefined); expect(output.result).toContain('Loading'); }); + + // @gate experimental + it('should not attempt to render the fallback if the main content completes first', async () => { + const {writable, output, completed} = getTestWritable(); + + let renderedFallback = false; + function Fallback() { + renderedFallback = true; + return 'Loading...'; + } + function Content() { + return 'Hi'; + } + ReactDOMFizzServer.pipeToNodeWritable( + }> + + , + writable, + ); + + await completed; + + expect(output.result).toContain('Hi'); + expect(output.result).not.toContain('Loading'); + expect(renderedFallback).toBe(false); + }); + + // @gate experimental + it('should be able to complete by aborting even if the promise never resolves', async () => { + const {writable, output, completed} = getTestWritable(); + const {abort} = ReactDOMFizzServer.pipeToNodeWritable( +
+ Loading
}> + + + , + writable, + ); + + jest.runAllTimers(); + + expect(output.result).toContain('Loading'); + + abort(); + + await completed; + + expect(output.error).toBe(undefined); + expect(output.result).toContain('Loading'); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 38489f996c092..cb15bd197300e 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -14,19 +14,31 @@ import { createRequest, startWork, startFlowing, + abort, } from 'react-server/src/ReactFizzServer'; function createDrainHandler(destination, request) { return () => startFlowing(request); } +type Controls = { + // Cancel any pending I/O and put anything remaining into + // client rendered mode. + abort(): void, +}; + function pipeToNodeWritable( children: ReactNodeList, destination: Writable, -): void { +): Controls { const request = createRequest(children, destination); destination.on('drain', createDrainHandler(destination, request)); startWork(request); + return { + abort() { + abort(request); + }, + }; } export {pipeToNodeWritable}; diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index d4b60739c619f..4553fa43cd4f9 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -216,6 +216,9 @@ function render(children: React$Element): Destination { placeholders: new Map(), segments: new Map(), stack: [], + abort() { + ReactNoopServer.abort(request); + }, }; const request = ReactNoopServer.createRequest(children, destination); ReactNoopServer.startWork(request); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5eff77a57a29c..aff4c50374618 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -61,6 +61,7 @@ type SuspenseBoundary = { pendingWork: number, // when it reaches zero we can show this boundary's content completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. + fallbackAbortableWork: Set, // used to cancel work on the fallback if the boundary completes or gets canceled. }; type SuspendedWork = { @@ -68,18 +69,20 @@ type SuspendedWork = { ping: () => void, blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, // the segment we'll write to + abortSet: Set, // the abortable set that this work belongs to assignID: null | SuspenseBoundaryID, // id to assign to the content }; const PENDING = 0; const COMPLETED = 1; const FLUSHED = 2; -const ERRORED = 3; +const ABORTED = 3; +const ERRORED = 4; type Root = null; type Segment = { - status: 0 | 1 | 2 | 3, + status: 0 | 1 | 2 | 3 | 4, parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed id: number, // starts as 0 and is lazily assigned if the parent flushes early +index: number, // the index within the parent's chunks or 0 at the root @@ -102,6 +105,7 @@ type Request = { allPendingWork: number, // when it reaches zero, we can close the connection. pendingRootWork: number, // when this reaches zero, we've finished at least the root boundary. completedRootSegment: null | Segment, // Completed but not yet flushed root segments. + abortableWork: Set, pingedWork: Array, // Queues to flush in order of priority clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. @@ -114,6 +118,7 @@ export function createRequest( destination: Destination, ): Request { const pingedWork = []; + const abortSet: Set = new Set(); const request = { destination, responseState: createResponseState(), @@ -123,6 +128,7 @@ export function createRequest( allPendingWork: 0, pendingRootWork: 0, completedRootSegment: null, + abortableWork: abortSet, pingedWork: pingedWork, clientRenderedBoundaries: [], completedBoundaries: [], @@ -137,6 +143,7 @@ export function createRequest( children, null, rootSegment, + abortSet, null, ); pingedWork.push(rootWork); @@ -151,7 +158,10 @@ function pingSuspendedWork(request: Request, work: SuspendedWork): void { } } -function createSuspenseBoundary(request: Request): SuspenseBoundary { +function createSuspenseBoundary( + request: Request, + fallbackAbortableWork: Set, +): SuspenseBoundary { return { id: createSuspenseBoundaryID(request.responseState), rootSegmentID: -1, @@ -160,6 +170,7 @@ function createSuspenseBoundary(request: Request): SuspenseBoundary { forceClientRender: false, completedSegments: [], byteSize: 0, + fallbackAbortableWork, }; } @@ -168,6 +179,7 @@ function createSuspendedWork( node: ReactNodeList, blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, + abortSet: Set, assignID: null | SuspenseBoundaryID, ): SuspendedWork { request.allPendingWork++; @@ -181,8 +193,10 @@ function createSuspendedWork( ping: () => pingSuspendedWork(request, work), blockedBoundary, blockedSegment, + abortSet, assignID, }; + abortSet.add(work); return work; } @@ -219,6 +233,7 @@ function renderNode( parentBoundary: Root | SuspenseBoundary, segment: Segment, node: ReactNodeList, + abortSet: Set, assignID: null | SuspenseBoundaryID, ): void { if (typeof node === 'string') { @@ -229,9 +244,9 @@ function renderNode( if (Array.isArray(node)) { if (node.length > 0) { // Only the first node gets assigned an ID. - renderNode(request, parentBoundary, segment, node[0], assignID); + renderNode(request, parentBoundary, segment, node[0], abortSet, assignID); for (let i = 1; i < node.length; i++) { - renderNode(request, parentBoundary, segment, node[i], null); + renderNode(request, parentBoundary, segment, node[i], abortSet, null); } } else { pushEmpty(segment.chunks, request.responseState, assignID); @@ -252,7 +267,7 @@ function renderNode( if (typeof type === 'function') { try { const result = type(props); - renderNode(request, parentBoundary, segment, result, assignID); + renderNode(request, parentBoundary, segment, result, abortSet, assignID); } catch (x) { if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // Something suspended, we'll need to create a new segment and resolve it later. @@ -264,6 +279,7 @@ function renderNode( node, parentBoundary, newSegment, + abortSet, assignID, ); const ping = suspendedWork.ping; @@ -282,7 +298,14 @@ function renderNode( request.responseState, assignID, ); - renderNode(request, parentBoundary, segment, props.children, null); + renderNode( + request, + parentBoundary, + segment, + props.children, + abortSet, + null, + ); pushEndInstance(segment.chunks, type, props); } else if (type === REACT_SUSPENSE_TYPE) { // We need to push an "empty" thing here to identify the parent suspense boundary. @@ -294,7 +317,8 @@ function renderNode( const fallback: ReactNodeList = props.fallback; const content: ReactNodeList = props.children; - const newBoundary = createSuspenseBoundary(request); + const fallbackAbortSet: Set = new Set(); + const newBoundary = createSuspenseBoundary(request, fallbackAbortSet); const insertionIndex = segment.chunks.length; // The children of the boundary segment is actually the fallback. @@ -304,6 +328,7 @@ function renderNode( newBoundary, ); segment.children.push(boundarySegment); + // We create suspended work for the fallback because we don't want to actually work // on it yet in case we finish the main content, so we queue for later. const suspendedFallbackWork = createSuspendedWork( @@ -311,6 +336,7 @@ function renderNode( fallback, parentBoundary, boundarySegment, + fallbackAbortSet, newBoundary.id, // This is the ID we want to give this fallback so we can replace it later. ); // TODO: This should be queued at a separate lower priority queue so that we only work @@ -330,6 +356,7 @@ function renderNode( content, newBoundary, contentRootSegment, + abortSet, null, ); retryWork(request, contentWork); @@ -338,14 +365,12 @@ function renderNode( } } -function errorWork( +function erroredWork( request: Request, boundary: Root | SuspenseBoundary, segment: Segment, error: mixed, ) { - segment.status = ERRORED; - request.allPendingWork--; if (boundary !== null) { boundary.pendingWork--; @@ -370,12 +395,55 @@ function errorWork( } } -function completeWork( +function abortWorkSoft(suspendedWork: SuspendedWork): void { + // This aborts work without aborting the parent boundary that it blocks. + // It's used for when we didn't need this work to complete the tree. + // If work was needed, then it should use abortWork instead. + const request: Request = this; + const boundary = suspendedWork.blockedBoundary; + const segment = suspendedWork.blockedSegment; + segment.status = ABORTED; + finishedWork(request, boundary, segment); +} + +function abortWork(suspendedWork: SuspendedWork): void { + // This aborts the work and aborts the parent that it blocks, putting it into + // client rendered mode. + const request: Request = this; + const boundary = suspendedWork.blockedBoundary; + const segment = suspendedWork.blockedSegment; + segment.status = ABORTED; + + request.allPendingWork--; + if (boundary === null) { + // We didn't complete the root so we have nothing to show. We can close + // the request; + if (request.status !== CLOSED) { + request.status = CLOSED; + close(request.destination); + } + } else { + boundary.pendingWork--; + + // If this boundary was still pending then we haven't already cancelled its fallbacks. + // We'll need to abort the fallbacks, which will also error that parent boundary. + boundary.fallbackAbortableWork.forEach(abortWork, request); + boundary.fallbackAbortableWork.clear(); + + if (!boundary.forceClientRender) { + boundary.forceClientRender = true; + if (boundary.parentFlushed) { + request.clientRenderedBoundaries.push(boundary); + } + } + } +} + +function finishedWork( request: Request, boundary: Root | SuspenseBoundary, segment: Segment, ) { - segment.status = COMPLETED; request.allPendingWork--; if (boundary === null) { @@ -397,6 +465,9 @@ function completeWork( } if (boundary.pendingWork === 0) { // This must have been the last segment we were waiting on. This boundary is now complete. + // We can now cancel any pending work on the fallback since we won't need to show it anymore. + boundary.fallbackAbortableWork.forEach(abortWorkSoft, request); + boundary.fallbackAbortableWork.clear(); if (segment.parentFlushed) { // Our parent segment already flushed, so we need to schedule this segment to be emitted. boundary.completedSegments.push(segment); @@ -425,7 +496,12 @@ function completeWork( function retryWork(request: Request, work: SuspendedWork): void { const segment = work.blockedSegment; + if (segment.status !== PENDING) { + // We completed this by other means before we had a chance to retry it. + return; + } const boundary = work.blockedBoundary; + const abortSet = work.abortSet; try { let node = work.node; while ( @@ -442,16 +518,20 @@ function retryWork(request: Request, work: SuspendedWork): void { node = element.type(element.props); } - renderNode(request, boundary, segment, node, work.assignID); + renderNode(request, boundary, segment, node, abortSet, work.assignID); - completeWork(request, boundary, segment); + abortSet.delete(work); + segment.status = COMPLETED; + finishedWork(request, boundary, segment); } catch (x) { if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // Something suspended again, let's pick it back up later. const ping = work.ping; x.then(ping, ping); } else { - errorWork(request, boundary, segment, x); + abortSet.delete(work); + segment.status = ERRORED; + erroredWork(request, boundary, segment, x); } } } @@ -797,6 +877,13 @@ function flushCompletedQueues(request: Request): void { // We don't need to check any partially completed segments because // either they have pending work or they're complete. ) { + if (__DEV__) { + if (request.abortableWork.size !== 0) { + console.error( + 'There was still abortable work at the root when we closed. This is a bug in React.', + ); + } + } // We're done. close(destination); } @@ -824,6 +911,20 @@ export function startFlowing(request: Request): void { } } +// This is called to early terminate a request. It puts all pending boundaries in client rendered state. +export function abort(request: Request): void { + try { + const abortableWork = request.abortableWork; + abortableWork.forEach(abortWork, request); + abortableWork.clear(); + if (request.status === FLOWING) { + flushCompletedQueues(request); + } + } catch (error) { + fatalError(request, error); + } +} + function notYetImplemented(): void { throw new Error('Not yet implemented.'); } From 6bc8670934cf7e9b2e84e82663e0b05631243fd4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 17 Mar 2021 21:33:27 -0400 Subject: [PATCH 2/5] Expose abort API to the browser streams Since this API already returns a value, we need to use destructuring to expose more options. --- fixtures/fizz-ssr-browser/index.html | 2 +- .../ReactDOMFizzServerBrowser-test.js | 24 +++++++++++++++---- .../src/server/ReactDOMFizzServerBrowser.js | 16 +++++++++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/fixtures/fizz-ssr-browser/index.html b/fixtures/fizz-ssr-browser/index.html index 75d3f87b57abb..1e76c00693e55 100644 --- a/fixtures/fizz-ssr-browser/index.html +++ b/fixtures/fizz-ssr-browser/index.html @@ -20,7 +20,7 @@

Fizz Example