react-async-ssr
Version:
Render React Suspense on server
434 lines (364 loc) • 12.1 kB
JavaScript
/* --------------------
* react-async-ssr module
* Renderer class
* ------------------*/
'use strict';
// Modules
const {toArray} = require('react').Children;
const {last} = require('lodash');
// Imports
const {TYPE_SUSPENSE, TYPE_PROMISE, TYPE_FALLBACK, TYPE_TEXT} = require('./constants.js'),
{ReactDOMServerRenderer, isDev} = require('./rendererSuper.js'),
shimMethods = require('./shim.js'),
handleMethods = require('./handle.js'),
nodeMethods = require('./nodes.js'),
{abort} = require('./abort.js'),
treeToHtml = require('./treeToHtml.js'),
walkTree = require('./walkTree.js'),
{isRenderableElement} = require('./utils.js');
// Exports
class Renderer extends ReactDOMServerRenderer {
/**
* @constructor
* @param {*} children - React element(s)
* @param {boolean} makeStaticMarkup - `true` for equivalent of `renderToStaticMarkup()`
* @param {boolean} fallbackFast - `true` to bail out of Suspense nodes as soon as suspended
*/
constructor(children, makeStaticMarkup, fallbackFast) {
super(children, makeStaticMarkup);
// Record options
this.fallbackFast = fallbackFast;
// Init boundary tree
const tree = {
type: null,
parent: null,
children: [],
frame: null
};
this.tree = tree;
this.node = tree;
this.suspenseNode = null;
// Init lazy node awaiting counter
this.numAwaiting = 0;
this.fallbacksQueue = [];
// Init interrupt signal
this.interrupt = null;
// Init read callback
this.callback = null;
// Shim `stack.pop()` method to catch when boundary completes processing
this.stackPopOriginal = this.stack.pop;
this.stack.pop = () => this.stackPop();
// Init var to track position relative to DOM root
// TODO Remove this once PR to do same lands in React
// https://github.com/facebook/react/pull/15023
this.domRootIndex = 0;
// Set footer on root stack frame to prevent thread being freed at end of render
this.stack[0].footer = '_';
}
/**
* Read async.
* Async equivalent of ReactDOM's `.read()`.
* @param {Function} cb - Callback to call with HTML output
* @returns {undefined}
*/
readAsync(cb) {
this.callback = cb;
// Render
this.cycle();
// If done or errored, exit
if (this.exhausted) return;
// Process fallbacks requiring render
this.drainFallbacksQueue();
}
/**
* Enter sync render cycle.
* Renders using ReactDOM's `.read()` method.
*
* If promises are pending at end of cycle, exit - a new cycle will begin
* when promises resolve.
* If no promises pending, call callback with rendered HTML output.
* If error thrown during render, call callback with error.
*
* Stack must be have only 1 frame when calling this method.
*
* @returns {undefined}
*/
cycle() {
try {
// Call React's `.read()` method to read 1 byte.
// Read only 1 so thread is not destroyed at end of cycle.
// (bottom stack frame outputs 1 char which stops rendering before stack unwinds completely)
this.read(1);
} catch (err) {
this.errored(err);
return;
}
if (this.numAwaiting === 0 && this.fallbacksQueue.length === 0) this.done();
}
/*
* If a promise marked no SSR is thrown during render, a Suspense boundary's
* fallback needs to be triggered.
* If the Suspense boundary is being rendered in current render cycle,
* fallback will be rendered synchronously during cycle.
* But if the Suspense boundary was introduced in a previous cycle, it is
* added to the fallbacks queue.
* This method renders the next fallback in the queue in its correct
* position in the node tree.
* When queue is fully drained, and if no pending promises remain, render is
* complete and callback is called.
*/
drainFallbacksQueue() {
// Process fallbacks queue - convert nodes to fallbacks.
// Find first fallback that requires rendering, and render it.
const {fallbacksQueue} = this;
let suspenseNode, fallback;
while (true) { // eslint-disable-line no-constant-condition
if (fallbacksQueue.length === 0) {
if (this.numAwaiting === 0) this.done();
return;
}
// Convert node to fallback and discard children
suspenseNode = fallbacksQueue.pop();
fallback = suspenseNode.fallback;
convertNodeToFallback(suspenseNode);
// Stop when found a fallback that requires rendering
if (isRenderableElement(fallback)) break;
suspenseNode.stackState = null;
}
// Render fallback
this.rerender(suspenseNode, fallback);
}
/**
* Render element in tree.
* Stack state of node is restored, and element is rendered.
* @param {Object} node - Tree node to render in
* @param {Object} element - React element to render
* @returns {undefined}
*/
rerender(node, element) {
// Reinstate stack state with element on stack ready to render
// NB Stack should always be length 0 at this point
const {stackState} = node;
node.stackState = null;
const frame = {
type: null,
domNamespace: stackState.domNamespace,
children: toArray(element),
childIndex: 0,
context: stackState.context, // Legacy Context API
footer: '_' // Prevent thread being freed at end of render
};
if (isDev) frame.debugElementStack = [];
this.stack[0] = frame;
// Restore contexts from new Context API
for (const ctx of stackState.contexts) {
this.pushProvider(
isDev ? ctx.provider : {type: {_context: ctx.context}, props: {value: ctx.value}}
);
}
// Restore `currentSelectValue` + `domRootIndex`
this.currentSelectValue = stackState.currentSelectValue;
this.domRootIndex = stackState.domRootAbove ? 1 : 0;
this.previousWasTextNode = false;
// Step into node
this.node = node;
this.suspenseNode = node.parentSuspense;
// Render element
this.cycle();
// If errored, exit
if (this.exhausted) return;
// Clear contexts added in `.restoreStack()`
// NB ReactDOM's native `.clearProviders()` method resets the content of all
// Providers, but does not reset `.contextIndex`.
this.clearProviders();
this.contextIndex = -1;
// Process fallbacks requiring render
this.drainFallbacksQueue();
}
/*
* Rendering complete.
* Call callback with HTML output.
*/
done() {
// Finished processing
this.destroy();
// Convert tree to HTML and callback with result
// NB Do not leak `this` to callback
const out = treeToHtml(this.tree, this.makeStaticMarkup, this.fallbackFast);
const {callback} = this;
callback(null, out);
}
/**
* Render has failed. Call callback with error.
* Clean up prior to exit involves aborting all pending promises.
* Either an error has occured synchronously during a render cycle, or
* a promise which was previously thrown was rejected.
* @param {*} err - Error from render/rejected promise
* @returns {undefined}
*/
errored(err) {
// Abort all promises
walkTree(this.tree, (node) => { // eslint-disable-line consistent-return
const {type} = node;
if (type === TYPE_SUSPENSE) {
if (node.suspended) return walkTree.STOP;
} else if (type === TYPE_PROMISE && !node.resolved) {
this.abort(node);
}
});
// Clear fallbacks queue
this.fallbacksQueue.length = 0;
// Destroy + callback with error
// NB Do not leak `this` to callback
this.destroy();
const {callback} = this;
callback(err);
}
/*
* Abort a promise node.
* Mark node as resolved and call promise's `[ABORT]()` method.
*/
abort(node) {
node.resolved = true;
node.stackState = null;
abort(node.promise);
this.numAwaiting--;
}
/*
* Extension of superclass's `.render()` method.
* Allows capturing Suspense elements and Promises thrown while rendering
* elements ("interrupts").
*/
render(element, context, parentNamespace) {
// Shim element to capture thrown errors + suspense elements
element = this.shimElement(element);
// Call original render method
const out = super.render(element, context, parentNamespace);
// Handle interrupts (i.e. Suspense element or thrown Promise)
if (this.interrupt !== null) {
this.handleInterrupt();
} else {
// No interrupts - add output to tree
this.output(out);
}
return '';
}
/*
* Extension of superclass's `.renderDOM()` method.
*
* Hide `footer` from ReactDOM to prevent output which would halt render
* cycle (due to `.read()` being called with length of 1 bytes).
* Footer is instead stored in `._footer` which is added to output when
* frame is popped in `.stackPop()` method below.
*
* Also fix `data-reactroot=""` markup to workaround bug in ReactDOM.
* https://github.com/facebook/react/issues/15012
*/
renderDOM(element, context, parentNamespace) {
// Render
let out = super.renderDOM(element, context, parentNamespace);
// Set `domRootIndex` if this is root DOM element
// TODO Remove this once PR to do same lands in React
// https://github.com/facebook/react/pull/15023
if (this.domRootIndex === 0) this.domRootIndex = this.stack.length;
// Fix markup where root node incorrectly identified
// TODO Remove this once PR to add `.domRootIndex` lands in React
// https://github.com/facebook/react/pull/15023
if (!this.makeStaticMarkup) out = this.fixMarkup(out);
// Move footer into _footer
const frame = last(this.stack);
frame._footer = frame.footer;
frame.footer = '';
return out;
}
fixMarkup(out) {
// Remove `data-reactroot=""` if nested render, or add if inside boundary.
// NB `super.renderDOM()` has added a frame to the stack.
const isDomRoot = this.domRootIndex === this.stack.length;
if (this.stack.length === 2) {
if (!isDomRoot) {
// Remove tag
out = out.replace(
/^(<.+?) data-reactroot=""(\/?>)/,
(whole, start, end) => `${start}${end}`
);
}
} else if (isDomRoot) {
// Add tag
out = out.replace(
/^(<.+?)(\/?>)/,
(whole, start, end) => `${start} data-reactroot=""${end}`
);
}
return out;
}
/*
* Shim of `this.stack.pop()`. Shim is applied in class constructor.
* Used to detect when exiting a Suspense or Promise node in the tree.
* If exiting a Suspense node which has been suspended, fallback is
* rendered.
*/
stackPop() {
// Reset `domRootIndex` if DOM root element is being popped from stack
// TODO Remove this once PR to do same lands in React
// https://github.com/facebook/react/pull/15023
if (this.stack.length === this.domRootIndex) this.domRootIndex = 0;
// Call original `.pop()` method
const frame = this.stackPopOriginal.call(this.stack);
// If node completed processing, traverse back up tree
const {node} = this;
if (frame === node.frame) {
node.frame = null;
this.node = node.parent;
this.previousWasTextNode = false;
if (node === this.suspenseNode) this.popSuspense(node, frame);
} else if (frame._footer !== undefined) {
this.output(frame._footer);
}
return frame;
}
popSuspense(node, frame) {
const {fallback} = node;
// Step down suspense stack
this.suspenseNode = node.parentSuspense;
// TODO Clear stack state of Suspense nodes when definitively resolved
if (!node.suspended) return;
// Was suspended
// Convert node to fallback
convertNodeToFallback(node);
node.stackState = null;
if (!isRenderableElement(fallback)) return;
// Render fallback
frame.children = toArray(fallback);
frame.childIndex = 0;
this.stack.push(frame);
this.node = node;
node.frame = frame;
}
/**
* Add output to tree.
* @param {string} out - HTML output
* @returns {undefined}
*/
output(out) {
if (out === '') return;
const {children} = this.node;
const lastChild = children.length === 0 ? null : last(children);
if (lastChild && lastChild.type === TYPE_TEXT) {
// Existing text node - append to it
lastChild.out += out;
} else {
// Create new text node
children.push({type: TYPE_TEXT, out});
}
}
}
function convertNodeToFallback(node) {
node.type = TYPE_FALLBACK;
node.fallback = null;
node.suspendedChildren = node.children;
node.children = [];
}
// Add shim, handle and node methods
Object.assign(Renderer.prototype, shimMethods, handleMethods, nodeMethods);
module.exports = Renderer;