Skip to content

Commit

Permalink
Merge 8677fa7 into d7abd4e
Browse files Browse the repository at this point in the history
  • Loading branch information
sventschui committed May 6, 2019
2 parents d7abd4e + 8677fa7 commit 651a2cc
Show file tree
Hide file tree
Showing 11 changed files with 700 additions and 9 deletions.
10 changes: 7 additions & 3 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 @@ -376,7 +376,9 @@ export {
memo,
forwardRef,
// eslint-disable-next-line camelcase
unstable_batchedUpdates
unstable_batchedUpdates,
Suspense,
lazy
};

// React copies the named exports to the default one.
Expand All @@ -399,5 +401,7 @@ export default assign({
PureComponent,
memo,
forwardRef,
unstable_batchedUpdates
unstable_batchedUpdates,
Suspense,
lazy
}, hooks);
16 changes: 16 additions & 0 deletions debug/src/debug.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ export function initDebug() {

// Check prop-types if available
if (typeof vnode.type==='function' && vnode.type.propTypes) {
if (vnode.type.name === 'Lazy') {
const m = 'PropTypes are not supported on lazy(). Use propTypes on the wrapped component itself. ';
try {
const lazied = vnode.type();
console.warn(m + 'Component wrapped in lazy() is ' + lazied.then.displayName || lazied.then.name);
}
catch (promise) {
console.warn(m + 'We will log the wrapped component\'s name once it is loaded.');
if (promise.then) {
promise.then((exports) => {
console.warn('Component wrapped in lazy() is ' + (exports.default.displayName || exports.default.name));
});
}

}
}
checkPropTypes(vnode.type.propTypes, vnode.props, getDisplayName(vnode), serializeVNode(vnode));
}

Expand Down
52 changes: 50 additions & 2 deletions debug/test/browser/debug.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement as h, options, render, createRef, Component, Fragment } from 'preact';
import { createElement as h, options, render, createRef, Component, Fragment, lazy, Suspense } from 'preact';
import { useState, useEffect, useLayoutEffect, useMemo, useCallback } from 'preact/hooks';
import { act } from 'preact/test-utils';
import { act, setupRerender } from 'preact/test-utils';
import { setupScratch, teardown, clearOptions, serializeHtml } from '../../../test/_util/helpers';
import { serializeVNode, initDebug } from '../../src/debug';
import * as PropTypes from 'prop-types';
Expand Down Expand Up @@ -392,5 +392,53 @@ describe('debug', () => {
render(<Bar text="foo" />, scratch);
expect(console.error).to.not.be.called;
});

it('should validate propTypes inside lazy()', () => {
const rerender = setupRerender();

function Baz(props) {
return <h1>{props.unhappy}</h1>;
}

Baz.propTypes = {
unhappy: function alwaysThrows(obj, key) { if (obj[key] === 'signal') throw Error('got prop inside lazy()'); }
};


const loader = Promise.resolve({ default: Baz });
const LazyBaz = lazy(() => loader);

render(
<Suspense fallback={<div>fallback...</div>}>
<LazyBaz unhappy="signal" />
</Suspense>,
scratch
);

expect(console.error).to.not.be.called;

return loader.then(() => {
rerender();
expect(errors.length).to.equal(1);
expect(errors[0].includes('got prop')).to.equal(true);
expect(serializeHtml(scratch)).to.equal('<h1>signal</h1>');
});
});

it('should warn for PropTypes on lazy()', () => {
const loader = Promise.resolve({ default: function MyLazyLoadedComponent() { return <div>Hi there</div> } });
const FakeLazy = lazy(() => loader);
FakeLazy.propTypes = {};
render(
<Suspense fallback={<div>fallback...</div>} >
<FakeLazy />
</Suspense>,
scratch
);
return loader.then(() => {
expect(console.warn).to.be.calledTwice;
expect(warnings[1].includes('MyLazyLoadedComponent')).to.equal(true);
});
});
});
});
13 changes: 12 additions & 1 deletion demo/devtools.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
// eslint-disable-next-line no-unused-vars
import { createElement, Component, memo, Fragment } from "react";
import { createElement, Component, memo, Fragment, Suspense, lazy } from "react";

function Foo() {
return <div>I'm memoed</div>;
}

function LazyComp() {
return <div>I'm (fake) lazy loaded</div>;
}

const Lazy = lazy(() => Promise.resolve({ default: LazyComp }));

const Memoed = memo(Foo);

export default class DevtoolsDemo extends Component {
Expand All @@ -14,6 +20,11 @@ export default class DevtoolsDemo extends Component {
<h1>memo()</h1>
<p><b>functional component:</b></p>
<Memoed />
<h1>lazy()</h1>
<p><b>functional component:</b></p>
<Suspense fallback={<div>Loading (fake) lazy loaded component...</div>}>
<Lazy />
</Suspense>
</div>
);
}
Expand Down
3 changes: 3 additions & 0 deletions demo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import StyledComp from './styled-components';
import { initDevTools } from 'preact/debug/src/devtools';
import { initDebug } from 'preact/debug/src/debug';
import DevtoolsDemo from './devtools';
import SuspenseDemo from './suspense';

let isBenchmark = /(\/spiral|\/pythagoras|[#&]bench)/g.test(window.location.href);
if (!isBenchmark) {
Expand Down Expand Up @@ -71,6 +72,7 @@ class App extends Component {
<Link href="/people" activeClassName="active">People Browser</Link>
<Link href="/state-order" activeClassName="active">State Order</Link>
<Link href="/styled-components" activeClassName="active">Styled Components</Link>
<Link href="/suspense" activeClassName="active">Suspense / lazy</Link>
</nav>
</header>
<main>
Expand All @@ -96,6 +98,7 @@ class App extends Component {
<KeyBug path="/key_bug" />
<Context path="/context" />
<DevtoolsDemo path="/devtools" />
<SuspenseDemo path="/suspense" />
<EmptyFragment path="/empty-fragment" />
<PeopleBrowser path="/people/:user?" />
<StyledComp path="/styled-components" />
Expand Down
91 changes: 91 additions & 0 deletions demo/suspense.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// eslint-disable-next-line no-unused-vars
import { createElement, Component, memo, Fragment, Suspense, lazy } from "react";

function LazyComp() {
return <div>I'm (fake) lazy loaded</div>;
}

const Lazy = lazy(() => Promise.resolve({ default: LazyComp }));

function createSuspension(name, timeout, error) {
let done = false;
let prom;

return {
name,
timeout,
start: () => {
if (!prom) {
prom = new Promise((res, rej) => {
setTimeout(() => {
done = true;
if (error) {
rej(error);
}
else {
res();
}
}, timeout);
});
}

return prom;
},
getPromise: () => prom,
isDone: () => done
};
}

function CustomSuspense({ isDone, start, timeout, name }) {
if (!isDone()) {
throw start();
}

return (
<div>
Hello from CustomSuspense {name}, loaded after {timeout / 1000}s
</div>
);
}

function init() {
return {
s1: createSuspension('1', 1000, null),
s2: createSuspension('2', 2000, null),
s3: createSuspension('3', 3000, null)
};
}

export default class DevtoolsDemo extends Component {
constructor(props) {
super(props);
this.state = init();
this.onRerun = this.onRerun.bind(this);
}

onRerun() {
this.setState(init());
}

render(props, state) {
return (
<div>
<h1>lazy()</h1>
<Suspense fallback={<div>Loading (fake) lazy loaded component...</div>}>
<Lazy />
</Suspense>
<h1>Suspense</h1>
<div>
<button onClick={this.onRerun} >Rerun</button>
</div>
<Suspense fallback={<div>Fallback 1</div>}>
<CustomSuspense {...state.s1} />
<Suspense fallback={<div>Fallback 2</div>}>
<CustomSuspense {...state.s2} />
<CustomSuspense {...state.s3} />
</Suspense>
</Suspense>
</div>
);
}
}
30 changes: 28 additions & 2 deletions src/diff/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export function diff(parentDom, newVNode, oldVNode, context, isSvg, excessDomChi

try {
outer: if (oldVNode.type===Fragment || newType===Fragment) {
diffChildren(parentDom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, c, oldDom);
// Passing the ancestorComponent instead of c here is needed for catchErrorInComponent
// to properly traverse upwards through fragments to find a parent Suspense
diffChildren(parentDom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, ancestorComponent, oldDom);

// Mark dom as empty in case `_children` is any empty array. If it isn't
// we'll set `dom` to the correct value just a few lines later.
Expand Down Expand Up @@ -365,10 +367,22 @@ 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) {
if (component._childDidSuspend) {
component._childDidSuspend(error);
}
else {
continue;
}
}
else if (component.constructor.getDerivedStateFromError!=null) {
component.setState(component.constructor.getDerivedStateFromError(error));
}
else if (component.componentDidCatch!=null) {
Expand All @@ -381,8 +395,20 @@ function catchErrorInComponent(error, component) {
}
catch (e) {
error = e;
isSuspend = false;
}
}
}

// TODO: Add a react-like error message to preact/debug
/*
[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.
*/
if (isSuspend) {
return catchErrorInComponent(new Error('Missing Suspense'), 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';
51 changes: 51 additions & 0 deletions src/suspense.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Component } from './component';
import { createElement } from './create-element';

// having a "custom class" here saves 50bytes gzipped
export function Suspense(props) {}
Suspense.prototype = new Component();

/**
* @param {Promise} promise The thrown promise
*/
Suspense.prototype._childDidSuspend = function(promise) {
this.setState({ _loading: true });
const cb = () => { this.setState({ _loading: false }); };

// Suspense ignores errors thrown in Promises as this should be handled by user land code
promise.then(cb, cb);
};

Suspense.prototype.render = function(props, state) {
return state._loading ? props.fallback : props.children;
};

export function lazy(loader) {
let prom;
let component;
let error;

function Lazy(props) {
if (!prom) {
prom = loader();
prom.then(
(exports) => { component = exports.default; },
(e) => { error = e; },
);
}

if (error) {
throw error;
}

if (!component) {
throw prom;
}

return createElement(component, props);
}

Lazy.displayName = 'Lazy';

return Lazy;
}
11 changes: 10 additions & 1 deletion test/browser/lifecycle.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setupRerender } from 'preact/test-utils';
import { createElement as h, render, Component } from '../../src/index';
import { createElement as h, render, Component, Fragment } from '../../src/index';
import { setupScratch, teardown } from '../_util/helpers';

/** @jsx h */
Expand Down Expand Up @@ -2245,6 +2245,15 @@ describe('Lifecycle methods', () => {
expect(Receiver.prototype.componentDidCatch).to.have.been.called;
});

it('should be called when child inside a Fragment fails', () => {
function ThrowErr() {
throw new Error('Error!');
}

render(<Receiver><Fragment><ThrowErr /></Fragment></Receiver>, scratch);
expect(Receiver.prototype.componentDidCatch).to.have.been.called;
});

it('should re-render with new content', () => {
class ThrowErr extends Component {
componentWillMount() {
Expand Down

0 comments on commit 651a2cc

Please sign in to comment.