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 = '

    hello bar

    Hello foo

    '; + 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( + '

    hello bar

    Hello foo

    ' + ); + const removeChildSpy = sinon.spy(element.firstChild, 'removeChild'); + resolver(); + rerender(); + expect(removeChildSpy).to.be.not.called; + expect(element.innerHTML).to.equal( + '

    hello bar

    Hello foo

    ' + ); + }); }); 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'); });