diff --git a/src/diff/index.js b/src/diff/index.js index 8344b019e9..9c0d632124 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -255,19 +255,93 @@ export function diff( ? cloneNode(tmp.props.children) : tmp; - oldDom = diffChildren( - parentDom, - isArray(renderResult) ? renderResult : [renderResult], - newVNode, - oldVNode, - globalContext, - namespace, - excessDomChildren, - commitQueue, - oldDom, - isHydrating, - refQueue - ); + // Teardown old html fragment markers if content changed or transitioning away + if (newType === Fragment && oldVNode._dom && oldVNode._dom._endHtml) { + const endMarker = oldVNode._dom._endHtml; + oldDom = endMarker.nextSibling; + const newDSIH = newVNode.props.dangerouslySetInnerHTML; + if ( + !newDSIH || + newDSIH.__html !== oldVNode.props.dangerouslySetInnerHTML.__html + ) { + removeRange(oldVNode._dom, endMarker); + } else { + newVNode._dom = oldVNode._dom; + } + } + + if (newType === Fragment && newVNode.props.dangerouslySetInnerHTML) { + const html = newVNode.props.dangerouslySetInnerHTML.__html; + newVNode._children = []; + + // Unmount old children if transitioning from normal children + if (oldVNode._children && oldVNode._children.length) { + oldDom = getDomSibling(oldVNode); + for (let j = 0; j < oldVNode._children.length; j++) { + if (oldVNode._children[j]) { + unmount(oldVNode._children[j], newVNode); + } + } + } + + if (isHydrating) { + // Find start/end markers in excessDomChildren. + // We can't rely on oldDom because insert() skips comment nodes. + let startMarker, endMarker; + if (excessDomChildren) { + for (let j = 0; j < excessDomChildren.length; j++) { + const edc = excessDomChildren[j]; + if (!edc) continue; + if (!startMarker && edc.nodeType === 8 && edc.data === '$h') { + startMarker = edc; + } + if (startMarker) { + excessDomChildren[j] = NULL; + if (edc.nodeType === 8 && edc.data === '/$h') { + endMarker = edc; + break; + } + } + } + } + if (startMarker) { + newVNode._dom = startMarker; + startMarker._endHtml = endMarker; + } + oldDom = endMarker ? endMarker.nextSibling : NULL; + } + + // Mount: first render or update that cleared old markers + if (!newVNode._dom) { + const startMarker = document.createComment('$h'); + const endMarker = document.createComment('/$h'); + startMarker._endHtml = endMarker; + + parentDom.insertBefore(startMarker, oldDom); + if (html) { + const tpl = document.createElement('template'); + tpl.innerHTML = html; + parentDom.insertBefore(tpl.content, oldDom); + } + parentDom.insertBefore(endMarker, oldDom); + + newVNode._dom = startMarker; + } + } else { + oldDom = diffChildren( + parentDom, + isArray(renderResult) ? renderResult : [renderResult], + newVNode, + oldVNode, + globalContext, + namespace, + excessDomChildren, + commitQueue, + oldDom, + isHydrating, + refQueue + ); + } c.base = newVNode._dom; @@ -331,6 +405,11 @@ export function diff( if ((tmp = options.diffed)) tmp(newVNode); + // For html Fragments (bail-out path), advance oldDom past the end marker + if (newType === Fragment && newVNode._dom && newVNode._dom._endHtml) { + return newVNode._dom._endHtml.nextSibling; + } + return newVNode._flags & MODE_SUSPENDED ? undefined : oldDom; } @@ -341,6 +420,17 @@ function markAsForce(vnode) { } } +/** Remove all DOM nodes from `start` through `end` inclusive. */ +function removeRange(start, end) { + let node = start; + while (node) { + let next = node.nextSibling; + removeNode(node); + if (node === end) break; + node = next; + } +} + /** * @param {Array} commitQueue List of components * which have callbacks to invoke in commitRoot @@ -661,7 +751,11 @@ export function unmount(vnode, parentVNode, skipRemove) { } if (!skipRemove) { - removeNode(vnode._dom); + if (vnode.type === Fragment && vnode._dom && vnode._dom._endHtml) { + removeRange(vnode._dom, vnode._dom._endHtml); + } else { + removeNode(vnode._dom); + } } vnode._component = vnode._parent = vnode._dom = UNDEFINED; diff --git a/src/index-5.d.ts b/src/index-5.d.ts index f2bf9577e2..82e68b28eb 100644 --- a/src/index-5.d.ts +++ b/src/index-5.d.ts @@ -331,7 +331,9 @@ export function cloneElement

( // ----------------------------------- // TODO: Revisit what the public type of this is... -export const Fragment: FunctionComponent<{}>; +export const Fragment: FunctionComponent<{ + dangerouslySetInnerHTML?: PreactDOMAttributes['dangerouslySetInnerHTML']; +}>; // // Preact options diff --git a/src/index.d.ts b/src/index.d.ts index a91a61d7e9..51c838f643 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -304,7 +304,9 @@ export function cloneElement

( // ----------------------------------- // TODO: Revisit what the public type of this is... -export const Fragment: FunctionComponent<{}>; +export const Fragment: FunctionComponent<{ + dangerouslySetInnerHTML?: PreactDOMAttributes['dangerouslySetInnerHTML']; +}>; // // Preact options diff --git a/test/browser/hydrate.test.js b/test/browser/hydrate.test.js index 7a6370ed68..c15b0d2f73 100644 --- a/test/browser/hydrate.test.js +++ b/test/browser/hydrate.test.js @@ -515,4 +515,99 @@ describe('hydrate()', () => { rerender(); expect(scratch.innerHTML).to.equal('

Error!
'); }); + + describe('Fragment dangerouslySetInnerHTML', () => { + it('should hydrate Fragment with dangerouslySetInnerHTML', () => { + scratch.innerHTML = + '
beforecontentafter
'; + clearLog(); + + hydrate( +
+ before + content' }} /> + after +
, + scratch + ); + + expect(serializeHtml(scratch)).to.equal( + '
beforecontentafter
' + ); + expect(getLog()).to.deep.equal([]); + }); + + it('should hydrate then update Fragment dangerouslySetInnerHTML', () => { + scratch.innerHTML = '
first
'; + clearLog(); + + /** @type {(v) => void} */ + let set; + class App extends Component { + constructor(props) { + super(props); + set = this.setState.bind(this); + this.state = { html: 'first' }; + } + render() { + return ( +
+ +
+ ); + } + } + + hydrate(, scratch); + expect(serializeHtml(scratch)).to.equal( + '
first
' + ); + expect(getLog()).to.deep.equal([]); + + set({ html: 'second' }); + rerender(); + expect(serializeHtml(scratch)).to.equal( + '
second
' + ); + }); + + it('should hydrate with siblings around html Fragment', () => { + scratch.innerHTML = + '
beforehtmlafter
'; + clearLog(); + + hydrate( +
+ before + html' }} /> + after +
, + scratch + ); + + expect(serializeHtml(scratch)).to.equal( + '
beforehtmlafter
' + ); + expect(getLog()).to.deep.equal([]); + }); + + it('should hydrate multiple html Fragments as siblings', () => { + scratch.innerHTML = + '
ab
'; + clearLog(); + + hydrate( +
+ a' }} /> + b' }} /> +
, + scratch + ); + + expect(serializeHtml(scratch)).to.equal( + '
ab
' + ); + expect(getLog()).to.deep.equal([]); + }); + }); }); diff --git a/test/browser/render.test.js b/test/browser/render.test.js index eaa0e23657..23e3b03d54 100644 --- a/test/browser/render.test.js +++ b/test/browser/render.test.js @@ -788,6 +788,212 @@ describe('render()', () => { rerender(); expect(scratch.innerHTML).to.equal(''); }); + + describe('Fragment dangerouslySetInnerHTML', () => { + it('should mount Fragment with dangerouslySetInnerHTML', () => { + render( +
+ before + foo' }} /> + after +
, + scratch + ); + expect(serializeHtml(scratch)).to.equal( + '
beforefooafter
' + ); + }); + + it('should update Fragment dangerouslySetInnerHTML content', () => { + /** @type {(v) => void} */ + let set; + class App extends Component { + constructor(props) { + super(props); + set = this.setState.bind(this); + this.state = { html: 'first' }; + } + render() { + return ( +
+ +
+ ); + } + } + + render(, scratch); + expect(serializeHtml(scratch)).to.equal( + '
first
' + ); + + set({ html: 'second' }); + rerender(); + expect(serializeHtml(scratch)).to.equal( + '
second
' + ); + }); + + it('should not mutate DOM when __html is unchanged', () => { + /** @type {Component} */ + let thing; + class Thing extends Component { + constructor(props) { + super(props); + thing = this; + } + render() { + return ( +
+ same' }} + /> +
+ ); + } + } + + render(, scratch); + const startMarker = scratch.firstChild.firstChild; + const innerSpan = startMarker.nextSibling; + + thing.forceUpdate(); + rerender(); + + expect(startMarker.nextSibling).to.equal(innerSpan); + }); + + it('should unmount Fragment with dangerouslySetInnerHTML', () => { + /** @type {(v) => void} */ + let set; + class App extends Component { + constructor(props) { + super(props); + set = this.setState.bind(this); + this.state = { show: true }; + } + render() { + return this.state.show ? ( +
+ content' }} + /> +
+ ) : ( +
+ ); + } + } + + render(, scratch); + expect(serializeHtml(scratch)).to.equal( + '
content
' + ); + + set({ show: false }); + rerender(); + expect(serializeHtml(scratch)).to.equal('
'); + }); + + it('should handle falsy __html values', () => { + render( +
+ +
, + scratch + ); + expect(serializeHtml(scratch)).to.equal( + '
' + ); + }); + + it('should handle multiple html Fragments as siblings', () => { + render( +
+ a' }} /> + b' }} /> +
, + scratch + ); + expect(serializeHtml(scratch)).to.equal( + '
ab
' + ); + }); + + it('should transition from normal children to dangerouslySetInnerHTML', () => { + /** @type {(v) => void} */ + let set; + class App extends Component { + constructor(props) { + super(props); + set = this.setState.bind(this); + this.state = { useHtml: false }; + } + render() { + return ( +
+ {this.state.useHtml ? ( + raw' }} + /> + ) : ( + + children + + )} +
+ ); + } + } + + render(, scratch); + expect(scratch.innerHTML).to.equal('
children
'); + + set({ useHtml: true }); + rerender(); + expect(serializeHtml(scratch)).to.equal( + '
raw
' + ); + }); + + it('should transition from dangerouslySetInnerHTML to normal children', () => { + /** @type {(v) => void} */ + let set; + class App extends Component { + constructor(props) { + super(props); + set = this.setState.bind(this); + this.state = { useHtml: true }; + } + render() { + return ( +
+ {this.state.useHtml ? ( + raw' }} + /> + ) : ( + + children + + )} +
+ ); + } + } + + render(, scratch); + expect(serializeHtml(scratch)).to.equal( + '
raw
' + ); + + set({ useHtml: false }); + rerender(); + expect(scratch.innerHTML).to.equal('
children
'); + }); + }); }); it('should reconcile mutated DOM attributes', () => {