diff --git a/compat/test/browser/suspense-hydration.test.js b/compat/test/browser/suspense-hydration.test.js index b364cf41c6..befba4972f 100644 --- a/compat/test/browser/suspense-hydration.test.js +++ b/compat/test/browser/suspense-hydration.test.js @@ -723,61 +723,17 @@ describe('suspense hydration', () => { }); }); - // Currently not supported. Hydration doesn't set attributes... but should it - // when coming back from suspense if props were updated? - it.skip('should hydrate and update attributes with latest props', () => { - const originalHtml = '

Count: 0

Lazy count: 0

'; - scratch.innerHTML = originalHtml; - clearLog(); - - /** @type {() => void} */ - let increment; - const [Lazy, resolve] = createLazy(); - function App() { - const [count, setCount] = useState(0); - increment = () => setCount(c => c + 1); - - return ( - -

Count: {count}

- -
- ); - } - - hydrate(, scratch); - rerender(); // Flush rerender queue to mimic what preact will really do - expect(scratch.innerHTML).to.equal(originalHtml); - // Re: DOM OP below - Known issue with hydrating merged text nodes - expect(getLog()).to.deep.equal(['

Count: .appendChild(#text)']); - clearLog(); - - increment(); - rerender(); - - expect(scratch.innerHTML).to.equal( - '

Count: 1

Lazy count: 0

' - ); - expect(getLog()).to.deep.equal([]); - clearLog(); - - return resolve(({ count }) => ( -

Lazy count: {count}

- )).then(() => { - rerender(); - expect(scratch.innerHTML).to.equal( - '

Count: 1

Lazy count: 1

' - ); - // Re: DOM OP below - Known issue with hydrating merged text nodes - expect(getLog()).to.deep.equal(['

Lazy count: .appendChild(#text)']); - clearLog(); - }); - }); - - // Currently not supported, but I wrote the test before I realized that so - // leaving it here in case we do support it eventually - it.skip('should properly hydrate suspense when resolves to a Fragment', () => { - const originalHtml = ul([li(0), li(1), li(2), li(3), li(4), li(5)]); + it('should properly hydrate suspense when resolves to a Fragment', () => { + const originalHtml = ul([ + li(0), + li(1), + '', + li(2), + li(3), + '', + li(4), + li(5) + ]); const listeners = [ sinon.spy(), @@ -809,8 +765,8 @@ describe('suspense hydration', () => { scratch ); rerender(); // Flush rerender queue to mimic what preact will really do - expect(scratch.innerHTML).to.equal(originalHtml); expect(getLog()).to.deep.equal([]); + expect(scratch.innerHTML).to.equal(originalHtml); expect(listeners[5]).not.to.have.been.called; clearLog(); @@ -839,4 +795,228 @@ describe('suspense hydration', () => { expect(listeners[5]).to.have.been.calledTwice; }); }); + + it('should properly hydrate suspense when resolves to a Fragment without children', () => { + const originalHtml = ul([ + li(0), + li(1), + '', + '', + li(2), + li(3) + ]); + + const listeners = [sinon.spy(), sinon.spy(), sinon.spy(), sinon.spy()]; + + scratch.innerHTML = originalHtml; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + + + 0 + 1 + + + + + + 2 + 3 + + , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(getLog()).to.deep.equal([]); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(listeners[3]).not.to.have.been.called; + + clearLog(); + scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); + expect(listeners[3]).to.have.been.calledOnce; + + return resolve(() => null).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch + .querySelector('li:nth-child(2)') + .dispatchEvent(createEvent('click')); + expect(listeners[1]).to.have.been.calledOnce; + + scratch + .querySelector('li:last-child') + .dispatchEvent(createEvent('click')); + expect(listeners[3]).to.have.been.calledTwice; + }); + }); + + it('Should hydrate a fragment with multiple children correctly', () => { + scratch.innerHTML = '

Hello
World!
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + + + , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '
Hello
World!
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => ( + <> +
Hello
+
World!
+ + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello
World!
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + + it('Should hydrate a fragment with no children correctly', () => { + scratch.innerHTML = '
Hello world
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <> + + + +
Hello world
+ , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => null).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + + it('Should hydrate a fragment with no children correctly deeply', () => { + scratch.innerHTML = + '
Hello world
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + hydrate( + <> + + + + + + + +
Hello world
+ , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(p => p.children).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + return resolve2(() => null).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + }); + + it('Should hydrate a fragment with multiple children correctly deeply', () => { + scratch.innerHTML = + '

I am

Fragment
Hello world
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + hydrate( + <> + + + + + + + +
Hello world
+ , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '

I am

Fragment
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(p => p.children).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '

I am

Fragment
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + return resolve2(() => ( + <> +

I am

+ Fragment + + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '

I am

Fragment
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + }); }); diff --git a/src/diff/index.js b/src/diff/index.js index a05c03cc95..6adc68449e 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -68,8 +68,8 @@ export function diff( // If the previous diff bailed out, resume creating/hydrating. if (oldVNode._flags & MODE_SUSPENDED) { isHydrating = !!(oldVNode._flags & MODE_HYDRATE); - oldDom = newVNode._dom = oldVNode._dom; - excessDomChildren = [oldDom]; + excessDomChildren = oldVNode._component._excess; + oldDom = newVNode._dom = oldVNode._dom = excessDomChildren[0]; } if ((tmp = options._diff)) tmp(newVNode); @@ -290,15 +290,56 @@ export function diff( // if hydrating or creating initial tree, bailout preserves DOM: if (isHydrating || excessDomChildren != null) { if (e.then) { + let shouldFallback = true, + commentMarkersToFind = 0, + done = false; + newVNode._flags |= isHydrating ? MODE_HYDRATE | MODE_SUSPENDED : MODE_SUSPENDED; - while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) { - oldDom = oldDom.nextSibling; + newVNode._component._excess = []; + for (let i = 0; i < excessDomChildren.length; i++) { + let child = excessDomChildren[i]; + if (child == null || done) continue; + + // When we encounter a boundary with $s we are opening + // a boundary, this implies that we need to bump + // the amount of markers we need to find before closing + // the outer boundary. + // We exclude the open and closing marker from + // the future excessDomChildren but any nested one + // needs to be included for future suspensions. + if (child.nodeType == 8 && child.data == '$s') { + if (commentMarkersToFind > 0) { + newVNode._component._excess.push(child); + } + commentMarkersToFind++; + shouldFallback = false; + excessDomChildren[i] = null; + } else if (child.nodeType == 8 && child.data == '/$s') { + commentMarkersToFind--; + if (commentMarkersToFind > 0) { + newVNode._component._excess.push(child); + } + done = commentMarkersToFind === 0; + oldDom = excessDomChildren[i]; + excessDomChildren[i] = null; + } else if (commentMarkersToFind > 0) { + newVNode._component._excess.push(child); + excessDomChildren[i] = null; + } + } + + if (shouldFallback) { + while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) { + oldDom = oldDom.nextSibling; + } + + excessDomChildren[excessDomChildren.indexOf(oldDom)] = null; + newVNode._component._excess.push(oldDom); } - excessDomChildren[excessDomChildren.indexOf(oldDom)] = null; newVNode._dom = oldDom; } else { for (let i = excessDomChildren.length; i--; ) { diff --git a/src/internal.d.ts b/src/internal.d.ts index 7733b0f279..53f87508e6 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -62,8 +62,7 @@ export type ComponentChild = | undefined; export type ComponentChildren = ComponentChild[] | ComponentChild; -export interface FunctionComponent

- extends preact.FunctionComponent

{ +export interface FunctionComponent

extends preact.FunctionComponent

{ // Internally, createContext uses `contextType` on a Function component to // implement the Consumer component contextType?: PreactContext; @@ -164,6 +163,7 @@ export interface Component

extends preact.Component { state: S; // Override Component["state"] to not be readonly for internal use, specifically Hooks base?: PreactElement; + _excess?: PreactElement[]; _dirty: boolean; _force?: boolean; _renderCallbacks: Array<() => void>; // Only class components