Skip to content

Commit

Permalink
Merge 1d525f1 into e206b07
Browse files Browse the repository at this point in the history
  • Loading branch information
sventschui committed May 2, 2019
2 parents e206b07 + 1d525f1 commit 3b0a773
Show file tree
Hide file tree
Showing 5 changed files with 237 additions and 3 deletions.
6 changes: 4 additions & 2 deletions compat/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render as preactRender, cloneElement as preactCloneElement, createRef, h, Component, options, toChildArray, createContext, Fragment } from 'preact';
import { render as preactRender, cloneElement as preactCloneElement, createRef, h, Component, options, toChildArray, createContext, Fragment, Suspense, lazy } from 'preact';
import * as hooks from 'preact/hooks';
export * from 'preact/hooks';
import { assign } from '../../src/util';
Expand Down Expand Up @@ -401,5 +401,7 @@ export default assign({
PureComponent,
memo,
forwardRef,
unstable_batchedUpdates
unstable_batchedUpdates,
Suspense,
lazy
}, hooks);
30 changes: 29 additions & 1 deletion src/diff/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { diffChildren } from './children';
import { diffProps } from './props';
import { assign, removeNode } from '../util';
import options from '../options';
import { sym as suspenseSymbol } from '../suspense';

/**
* Diff two virtual nodes and apply proper changes to the DOM
Expand Down Expand Up @@ -362,10 +363,24 @@ function doRender(props, state, context) {
* component check for error boundary behaviors
*/
function catchErrorInComponent(error, component) {
// thrown Promises are meant to suspend...
let isSuspend = typeof error.then === 'function';
let suspendingComponent = component;

for (; component; component = component._ancestorComponent) {
if (!component._processingException) {
try {
if (component.constructor.getDerivedStateFromError!=null) {
if (isSuspend) {
// console.log('catchErrorInComponent component[suspenseSymbol]', component[suspenseSymbol]);
if (component[suspenseSymbol] === suspenseSymbol && component.componentDidCatch!=null) {
// console.log('hitting suspense...');
component.componentDidCatch(error);
}
else {
continue;
}
}
else if (component.constructor.getDerivedStateFromError!=null) {
component.setState(component.constructor.getDerivedStateFromError(error));
}
else if (component.componentDidCatch!=null) {
Expand All @@ -378,8 +393,21 @@ function catchErrorInComponent(error, component) {
}
catch (e) {
error = e;
isSuspend = typeof error.then === 'function';
suspendingComponent = component;
}
}
}

if (isSuspend && suspendingComponent) {
// TODO: what is the preact way to determine the component name?
const componentName = suspendingComponent.displayName || (suspendingComponent._vnode && suspendingComponent._vnode.type
&& suspendingComponent._vnode.type.name);

return catchErrorInComponent(new Error(`${componentName} suspended while rendering, but no fallback UI was specified.
Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.`), suspendingComponent);
}

throw error;
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export { Component } from './component';
export { cloneElement } from './clone-element';
export { createContext } from './create-context';
export { toChildArray } from './diff/children';
export { Suspense, lazy } from './suspense';
export { default as options } from './options';
70 changes: 70 additions & 0 deletions src/suspense.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Component } from './component';
import { createElement } from './create-element';

// TODO: react warns in dev mode about defaultProps and propTypes not being supported on lazy
// loaded components

export const sym = Symbol.for('Suspense');

export class Suspense extends Component {
constructor(props) {
// TODO: should we add propTypes in DEV mode?
super(props);

// mark this component as a handler of suspension (thrown Promises)
this[sym] = sym;

this.state = {
l: false
};
}

componentDidCatch(e) {
if (e && typeof e.then === 'function') {
this.setState({ l: true });
e.then(
() => {
this.setState({ l: false });
},
// TODO: what to do in error case?!
// we could store the error to the state and then throw it during render
// should have a look what react does in these cases...
() => {
this.setState({ l: false });
}
);
}
else {
throw e;
}
}

render() {
return this.state.l ? this.props.fallback : this.props.children;
}
}

export function lazy(loader) {
let prom;
let component;
let error;
return function Lazy(props) {
if (!prom) {
prom = loader();
prom.then(
({ default: c }) => { component = c; },
e => error = e,
);
}

if (error) {
throw error;
}

if (!component) {
throw prom;
}

return createElement(component, props);
};
}
133 changes: 133 additions & 0 deletions test/browser/suspense.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*eslint-env browser, mocha */
import { setupRerender } from 'preact/test-utils';
import { expect } from 'chai';
import { createElement as h, render, Component, Suspense, lazy } from '../../src/index';
import { setupScratch, teardown } from '../_util/helpers';

class LazyComp extends Component {
render() {
return <div>Hello Lazy</div>;
}
}

class CustomSuspense extends Component {
constructor(props) {
super(props);
this.state = { done: false };
}
render() {
if (!this.state.done) {
throw new Promise((res) => {
setTimeout(() => {
this.setState({ done: true });
res();
}, 0);
});
}

return (
<div>
Hello CustomSuspense
</div>
);
}
}

class Catcher extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}

componentDidCatch(e) {
this.setState({ error: e });
}

render() {
return this.state.error ? (
<div>
Catcher did catch: {this.state.error.message}
</div>
) : this.props.children;
}
}

const Lazy = lazy(() => new Promise((res) => {
setTimeout(() => {
res({ default: LazyComp });
}, 0);
}));

/** @jsx h */

describe('suspense', () => {
let scratch, rerender;

beforeEach(() => {
scratch = setupScratch();
rerender = setupRerender();
});

afterEach(() => {
teardown(scratch);
});

it('should suspend when using lazy', () => {
render(<Suspense fallback={<div>Suspended...</div>}>
<Lazy />
</Suspense>, scratch);
rerender();
expect(scratch.innerHTML).to.eql(
`<div>Suspended...</div>`
);
});

it('should suspend when a promise is throw', () => {
render(<Suspense fallback={<div>Suspended...</div>}>
<CustomSuspense />
</Suspense>, scratch);
rerender();
expect(scratch.innerHTML).to.eql(
`<div>Suspended...</div>`
);
});

it('should suspend with custom error boundary', () => {
render(<Suspense fallback={<div>Suspended...</div>}>
<Catcher>
<CustomSuspense />
</Catcher>
</Suspense>, scratch);
rerender();
expect(scratch.innerHTML).to.eql(
`<div>Suspended...</div>`
);
});

it('should only suspend the most inner Suspend', () => {
render(<Suspense fallback={<div>Suspended... 1</div>}>
Not suspended...
<Suspense fallback={<div>Suspended... 2</div>}>
<Catcher>
<CustomSuspense />
</Catcher>
</Suspense>
</Suspense>, scratch);
rerender();
expect(scratch.innerHTML).to.eql(
`Not suspended...<div>Suspended... 2</div>`
);
});

it('should throw when missing Suspense', () => {
render(<Catcher>
<CustomSuspense />
</Catcher>, scratch);
rerender();
expect(scratch.innerHTML).to.eql(
`<div>Catcher did catch: CustomSuspense suspended while rendering, but no fallback UI was specified.
Add a &lt;Suspense fallback=...&gt; component higher in the tree to provide a loading indicator or placeholder to display.</div>`
);
});
});

0 comments on commit 3b0a773

Please sign in to comment.