diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts
index 822684c990..f5c084ad36 100644
--- a/compat/src/internal.d.ts
+++ b/compat/src/internal.d.ts
@@ -15,7 +15,8 @@ export interface Component
extends PreactComponent
{
_childDidSuspend?(
error: Promise,
- suspendingComponent: Component
+ suspendingComponent: Component,
+ oldVNode?: VNode
): void;
_suspendedComponentWillUnmount?(): void;
}
diff --git a/compat/src/suspense.js b/compat/src/suspense.js
index 9051d075f2..5cf53a7411 100644
--- a/compat/src/suspense.js
+++ b/compat/src/suspense.js
@@ -11,7 +11,7 @@ options._catchError = function(error, newVNode, oldVNode) {
for (; (vnode = vnode._parent); ) {
if ((component = vnode._component) && component._childDidSuspend) {
// Don't call oldCatchError if we found a Suspense
- return component._childDidSuspend(error, newVNode._component);
+ return component._childDidSuspend(error, newVNode._component, oldVNode);
}
}
}
@@ -42,8 +42,13 @@ Suspense.prototype = new Component();
/**
* @param {Promise} promise The thrown promise
* @param {Component} suspendingComponent The suspending component
+ * @param {import('./internal').VNode | null | undefined} oldVNode old VNode caught in the _catchError options
*/
-Suspense.prototype._childDidSuspend = function(promise, suspendingComponent) {
+Suspense.prototype._childDidSuspend = function(
+ promise,
+ suspendingComponent,
+ oldVNode
+) {
/** @type {import('./internal').SuspenseComponent} */
const c = this;
@@ -79,7 +84,12 @@ Suspense.prototype._childDidSuspend = function(promise, suspendingComponent) {
}
};
- if (!c._suspensions++) {
+ /**
+ * We do not set `suspended: true` during hydration because we want the actual markup to remain on screen
+ * and hydrate it when the suspense actually gets resolved.
+ * While in non-hydration cases the usual fallbac -> component flow would occour.
+ */
+ if (!(oldVNode && oldVNode._hydrateDom) && !c._suspensions++) {
c.setState({ _suspended: (c._detachOnNextRender = c._vnode._children[0]) });
}
promise.then(onResolved, onResolved);
diff --git a/mangle.json b/mangle.json
index aa1cb02366..a72a1b0b93 100644
--- a/mangle.json
+++ b/mangle.json
@@ -27,6 +27,7 @@
"$_children": "__k",
"$_suspensions": "__u",
"$_dom": "__e",
+ "$_hydrateDom": "__s",
"$_component": "__c",
"$__html": "__html",
"$_parent": "__",
diff --git a/src/create-element.js b/src/create-element.js
index 779632dd7e..9da9d72e89 100644
--- a/src/create-element.js
+++ b/src/create-element.js
@@ -74,6 +74,7 @@ export function createVNode(type, props, key, ref) {
// a _lastDomChildSibling that has been set to `null`
_lastDomChildSibling: undefined,
_component: null,
+ _hydrateDom: null,
constructor: undefined
};
diff --git a/src/diff/children.js b/src/diff/children.js
index 86a121b36c..07377427ee 100644
--- a/src/diff/children.js
+++ b/src/diff/children.js
@@ -123,6 +123,10 @@ export function diffChildren(
firstChildDom = newDom;
}
+ if (excessDomChildren != null) {
+ excessDomChildren[excessDomChildren.indexOf(newDom)] = null;
+ }
+
let nextDom;
if (childVNode._lastDomChildSibling !== undefined) {
// Only Fragments or components that return Fragment like VNodes will
diff --git a/src/diff/index.js b/src/diff/index.js
index bf84a3f639..91e05e1e7e 100644
--- a/src/diff/index.js
+++ b/src/diff/index.js
@@ -40,6 +40,14 @@ export function diff(
// constructor as undefined. This to prevent JSON-injection.
if (newVNode.constructor !== undefined) return null;
+ // If this was the innermost VNode at a point where the tree suspended,
+ // pick up diffing where we left off using the saved DOM element and hydration state.
+ if (oldVNode._hydrateDom && excessDomChildren == null) {
+ newVNode._dom = oldDom = oldVNode._hydrateDom;
+ excessDomChildren = [oldDom];
+ oldVNode._hydrateDom = null;
+ }
+
if ((tmp = options._diff)) tmp(newVNode);
try {
@@ -213,6 +221,11 @@ export function diff(
if ((tmp = options.diffed)) tmp(newVNode);
} catch (e) {
+ if (isHydrating) {
+ // Before bailing out, mark the current VNode with the DOM element and hydration state.
+ // We can use this information if we return here to render later on.
+ oldVNode._hydrateDom = newVNode._dom = oldDom;
+ }
options._catchError(e, newVNode, oldVNode);
}
@@ -288,6 +301,11 @@ function diffElementNodes(
}
}
+ if (excessDomChildren != null && dom) {
+ excessDomChildren[excessDomChildren.indexOf(dom)] = null;
+ excessDomChildren = EMPTY_ARR.slice.call(dom.childNodes);
+ }
+
if (dom == null) {
if (newVNode.type === null) {
return document.createTextNode(newProps);
@@ -304,19 +322,10 @@ function diffElementNodes(
}
if (newVNode.type === null) {
- if (excessDomChildren != null) {
- excessDomChildren[excessDomChildren.indexOf(dom)] = null;
- }
-
if (oldProps !== newProps && dom.data != newProps) {
dom.data = newProps;
}
} else if (newVNode !== oldVNode) {
- if (excessDomChildren != null) {
- excessDomChildren[excessDomChildren.indexOf(dom)] = null;
- excessDomChildren = EMPTY_ARR.slice.call(dom.childNodes);
- }
-
oldProps = oldVNode.props || EMPTY_OBJ;
let oldHtml = oldProps.dangerouslySetInnerHTML;
diff --git a/test/browser/hydrate.test.js b/test/browser/hydrate.test.js
index 380416d89d..8dbda97b45 100644
--- a/test/browser/hydrate.test.js
+++ b/test/browser/hydrate.test.js
@@ -1,4 +1,6 @@
import { createElement, hydrate, Fragment } from 'preact';
+import { Suspense } from 'preact/compat';
+import { useState } from 'preact/hooks';
import {
setupScratch,
teardown,
@@ -7,11 +9,13 @@ import {
} from '../_util/helpers';
import { ul, li, div } from '../_util/dom';
import { logCall, clearLog, getLog } from '../_util/logCall';
+import { setupRerender } from 'preact/test-utils';
/** @jsx createElement */
describe('hydrate()', () => {
let scratch;
+ let rerender;
const List = ({ children }) => ;
const ListItem = ({ children }) => {children};
@@ -27,6 +31,7 @@ describe('hydrate()', () => {
beforeEach(() => {
scratch = setupScratch();
+ rerender = setupRerender();
});
afterEach(() => {
@@ -276,4 +281,45 @@ describe('hydrate()', () => {
hydrate(, element);
expect(element.innerHTML).to.equal('hello bar
');
});
+
+ it('should reuse suspense component markup when suspense resolves during hydration', () => {
+ scratch.innerHTML = '';
+ const element = scratch;
+ let resolver;
+ const Component = () => {
+ const [state, setState] = useState(false);
+ if (!state) {
+ throw new Promise(resolve => {
+ resolver = () => {
+ setState(true);
+ resolve();
+ };
+ });
+ } else {
+ return hello bar
;
+ }
+ };
+ const App = () => {
+ return (
+
+ baz
}>
+
+
+ Hello foo
+
+ );
+ };
+ hydrate(, element);
+ rerender();
+ expect(element.innerHTML).to.equal(
+ ''
+ );
+ const removeChildSpy = sinon.spy(element.firstChild, 'removeChild');
+ resolver();
+ rerender();
+ expect(removeChildSpy).to.be.not.called;
+ expect(element.innerHTML).to.equal(
+ ''
+ );
+ });
});
diff --git a/test/browser/render.test.js b/test/browser/render.test.js
index 63f6ce0448..cf8b4cfc3f 100644
--- a/test/browser/render.test.js
+++ b/test/browser/render.test.js
@@ -1276,21 +1276,25 @@ describe('render()', () => {
scratch.appendChild(container);
render(Hello
, scratch, scratch.firstElementChild);
expect(scratch.innerHTML).to.equal('Hello
');
+ const child = scratch.firstElementChild;
render(Hello
, scratch, scratch.firstElementChild);
expect(scratch.innerHTML).to.equal('Hello
');
+ expect(scratch.firstElementChild).to.equal(child);
});
it('should replaceNode after rendering', () => {
function App({ i }) {
return {i}
;
}
-
+ scratch.innerHTML = '';
render(, scratch);
expect(scratch.innerHTML).to.equal('2
');
+ const child = scratch.firstElementChild;
- render(, scratch, scratch.firstChild);
+ render(, scratch, child);
expect(scratch.innerHTML).to.equal('3
');
+ expect(scratch.firstElementChild).to.equal(child);
});
it('should not cause infinite loop with referentially equal props', () => {
@@ -1379,7 +1383,7 @@ describe('render()', () => {
it('should use replaceNode as render root and not inject into it', () => {
const childA = scratch.querySelector('#a');
render(contents
, scratch, childA);
- expect(scratch.querySelector('#a')).to.equalNode(childA);
+ expect(scratch.querySelector('#a')).to.equal(childA);
expect(childA.innerHTML).to.equal('contents');
});