Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
overlookmotel committed Jan 20, 2019
1 parent fde1183 commit 458a070
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 2 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = {
'valid-jsdoc': ['error'],

// Various other rules
'eqeqeq': ['error', 'always', {null: 'ignore'}],
'no-use-before-define': ['error', {functions: false}],
'no-unused-expressions': ['error'],
'no-var': ['error'],
Expand Down
197 changes: 196 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,200 @@

'use strict';

// Modules
const React = require('react'),
{createElement} = React,
{renderToNodeStream} = require('react-dom/server');

// Exports
module.exports = {};
module.exports = {
renderToStringAsync,
renderToStaticMarkupAsync
};

// Constants
const INTERRUPT_SUSPENSE = 'suspense',
INTERRUPT_PROMISE = 'promise',
PLACEHOLDER = '__interrupt__';

// Get Suspense element types
const {$$typeof: SUSPENSE_TYPEOF, type: SUSPENSE_TYPE} = createElement(React.Suspense, null);

function isSuspense(e) {
return e && e.$$typeof === SUSPENSE_TYPEOF && e.type === SUSPENSE_TYPE;
}

// Make Renderer subclass
const ReactDOMServerRenderer = renderToNodeStream('').partialRenderer.constructor;

class AsyncRenderer extends ReactDOMServerRenderer {
constructor(children, makeStaticMarkup, stackState) {
super(children, makeStaticMarkup);

if (stackState) {
// Not root node.
// Add frame to stack to reinstate stack state from parent render
// and avoid `data-reactroot=""` markup on first element.
const {stack} = this;
const topFrame = stack[0];

const frame = {
type: null,
domNamespace: stackState.domNamespace,
children: topFrame.children,
childIndex: 0,
// Restore contexts from legacy Context API
context: stackState.context,
footer: ''
};
if (topFrame.debugElementStack) frame.debugElementStack = [];

stack[1] = frame;
topFrame.children = [];

// Restore contexts from new Context API
const {threadID} = this;
this.contextStack = stackState.contexts.map(({context, value}) => {
context[threadID] = value;
return context;
});
this.contextIndex = this.contextStack.length - 1;
}

this._interrupts = [];
}

render(element, context, parentNamespace) {
if (isSuspense(element)) {
return this._interrupt(INTERRUPT_SUSPENSE, element, context, parentNamespace);
}

try {
return super.render(element, context, parentNamespace);
} catch (err) {
if (!isPromise(err)) throw err;
return this._interrupt(INTERRUPT_PROMISE, element, context, parentNamespace, {promise: err});
}
}

_interrupt(type, element, context, domNamespace, props) {
// Capture contexts from new Context API
const {threadID} = this;
const contexts = this.contextStack
.slice(0, this.contextIndex + 1)
.map(context => ({context, value: context[threadID]}));

// Save interrupt to array
const interrupt = {
type,
element,
stackState: {
// Capture contexts from legacy Context API
context,
contexts,
domNamespace
}
};
if (props) Object.assign(interrupt, props);

this._interrupts.push(interrupt);

// Return placeholder text.
// It will be replaced by result of interrupt in next render cycle
return PLACEHOLDER;
}
}

// Async render methods
function renderToStringAsync(element) {
return renderRoot(element, false);
}

function renderToStaticMarkupAsync(element) {
return renderRoot(element, true);
}

function renderRoot(element, makeStaticMarkup) {
return renderAsync(element, makeStaticMarkup, false, null);
}

function renderAsync(element, makeStaticMarkup, insideSuspense, stackState) {
return new Promise((resolve, reject) => {
const renderer = new AsyncRenderer(element, makeStaticMarkup, stackState);
try {
// Render element to HTML
let h = renderer.read(Infinity);

// If no interrupts, return full HTML
const interrupts = renderer._interrupts;
if (interrupts.length === 0) return resolve(h);

// Render was interrupted by Suspense or thrown promise.
// Split HTML into parts around interrupt placeholders
const parts = [];
h.split(PLACEHOLDER).forEach(part => parts.push(part, ''));
parts.length--;

// Resolve all interrupts and put resulting HTML into parts array
resolveInterrupts(interrupts, parts, makeStaticMarkup, insideSuspense)
.then(() => resolve(parts.join('')))
.catch(reject);
} finally {
renderer.destroy();
}
});
}

const resolvers = {
[INTERRUPT_SUSPENSE]: resolveSuspense,
[INTERRUPT_PROMISE]: resolvePromise
};

function resolveInterrupts(interrupts, parts, makeStaticMarkup, insideSuspense) {
return Promise.all(interrupts.map((interrupt, index) => {
const resolve = resolvers[interrupt.type];
if (!resolve) throw new Error(`Unknown interrupt type '${interrupt.type}'`);

return resolve(interrupt, makeStaticMarkup, insideSuspense)
.then(part => parts[index * 2 + 1] = part);
}));
}

function resolveSuspense(interrupt, makeStaticMarkup, insideSuspense) {
const {props} = interrupt.element;
return renderAsync(
props.children,
makeStaticMarkup,
true,
interrupt.stackState
).catch(err => {
if (!isPromise(err)) throw err;

return renderAsync(
props.fallback,
makeStaticMarkup,
insideSuspense,
interrupt.stackState
);
});
}

function resolvePromise(interrupt, makeStaticMarkup, insideSuspense) {
// If fatal interrupt, throw promise
const {promise} = interrupt;
if (!insideSuspense || promise._fatal) throw promise;

// Resolve promise, then render child again
return promise.then(() => {
return renderAsync(
interrupt.element,
makeStaticMarkup,
true,
interrupt.stackState
);
});
}

function isPromise(o) {
return o != null && typeof o.then === 'function';
}
42 changes: 42 additions & 0 deletions lazy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* --------------------
* react-async-ssr module
* `lazy` method
* ------------------*/

'use strict';

// Modules
const {createElement} = require('react');

// Exports
const isServer = typeof window === 'undefined';
const fatalPromise = {then: () => {}, _fatal: true};

function lazy(ctor, options) {
let noSsr = options ? options.ssr !== undefined && !options.ssr : false;

let loaded = false, errored = false, error, promise, Component;
return function(props) {
if (loaded) return createElement(Component, props);
if (errored) throw error;

if (noSsr && isServer) throw fatalPromise;

if (!promise) {
promise = ctor().then(
C => {
loaded = true;
Component = C;
},
err => {
errored = true;
error = err;
}
);
}

throw promise;
};
}

module.exports = lazy;
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"url": "https://github.com/overlookmotel/react-async-ssr/issues"
},
"dependencies": {
"react-dom": "^16.7.0"
},
"peerDependencies": {
"react": "^16.7.0"
},
"devDependencies": {
"chai": "^4.2.0",
Expand All @@ -22,7 +26,8 @@
"eslint": "^5.12.1",
"eslint-plugin-chai-friendly": "^0.4.1",
"istanbul": "^0.4.5",
"mocha": "^5.2.0"
"mocha": "^5.2.0",
"react": "^16.7.0"
},
"keywords": [
"react",
Expand Down

0 comments on commit 458a070

Please sign in to comment.