diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30cfc4ca6..948ae242c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ master ] + branches: [ master, v7 ] pull_request: - branches: [ master ] + branches: [ master, v7 ] jobs: unit-test: diff --git a/src/core/vdom.ts b/src/core/vdom.ts index f96ba4662..b173eee41 100644 --- a/src/core/vdom.ts +++ b/src/core/vdom.ts @@ -283,6 +283,14 @@ function isBodyWrapper(wrapper?: DNodeWrapper): boolean { return isVNodeWrapper(wrapper) && wrapper.node.tag === 'body'; } +function isHeadWrapper(wrapper?: DNodeWrapper): boolean { + return isVNodeWrapper(wrapper) && wrapper.node.tag === 'head'; +} + +function isSpecialWrapper(wrapper?: DNodeWrapper): boolean { + return isHeadWrapper(wrapper) || isBodyWrapper(wrapper) || isVirtualWrapper(wrapper); +} + function isAttachApplication(value: any): value is AttachApplication | DetachApplication { return !!value.type; } @@ -1392,7 +1400,7 @@ export function renderer(renderer: () => RenderResult): Renderer { } if (nextSibling.childDomWrapperId) { const childWrapper = _idToWrapperMap.get(nextSibling.childDomWrapperId); - if (childWrapper && !isBodyWrapper(childWrapper)) { + if (childWrapper && !isBodyWrapper(childWrapper) && !isHeadWrapper(childWrapper)) { domNode = childWrapper.domNode; } } @@ -2285,6 +2293,7 @@ export function renderer(renderer: () => RenderResult): Renderer { const parentDomNode = findParentDomNode(next)!; const isVirtual = isVirtualWrapper(next); const isBody = isBodyWrapper(next); + const isHead = isHeadWrapper(next); let mergeNodes: Node[] = []; next.id = `${wrapperId++}`; _idToWrapperMap.set(next.id, next); @@ -2297,6 +2306,8 @@ export function renderer(renderer: () => RenderResult): Renderer { } if (isBody) { next.domNode = global.document.body; + } else if (isHead) { + next.domNode = global.document.head; } else if (next.node.tag && !isVirtual) { if (next.namespace) { next.domNode = global.document.createElementNS(next.namespace, next.node.tag); @@ -2332,14 +2343,13 @@ export function renderer(renderer: () => RenderResult): Renderer { _idToChildrenWrappers.set(next.id, children); } } - const dom: ApplicationInstruction | undefined = - isVirtual || isBody - ? undefined - : { - next: next!, - parentDomNode: parentDomNode, - type: 'create' - }; + const dom: ApplicationInstruction | undefined = isSpecialWrapper(next) + ? undefined + : { + next: next!, + parentDomNode: parentDomNode, + type: 'create' + }; if (children) { return { item: { @@ -2380,8 +2390,7 @@ export function renderer(renderer: () => RenderResult): Renderer { } function _removeDom({ current }: RemoveDomInstruction): ProcessResult { - const isVirtual = isVirtualWrapper(current); - const isBody = isBodyWrapper(current); + const isSpecial = isSpecialWrapper(current); const children = _idToChildrenWrappers.get(current.id); _idToChildrenWrappers.delete(current.id); _idToWrapperMap.delete(current.id); @@ -2396,10 +2405,10 @@ export function renderer(renderer: () => RenderResult): Renderer { instanceData && instanceData.nodeHandler.remove(current.node.properties.key); } } - if (current.hasAnimations || isVirtual || isBody) { + if (current.hasAnimations || isSpecial) { return { item: { current: children, meta: {} }, - dom: isVirtual || isBody ? undefined : { type: 'delete', current } + dom: isSpecial ? undefined : { type: 'delete', current } }; } @@ -2407,7 +2416,7 @@ export function renderer(renderer: () => RenderResult): Renderer { _deferredRenderCallbacks.push(() => { let wrappers = children || []; let wrapper: DNodeWrapper | undefined; - let bodyIds = []; + let specialIds = []; while ((wrapper = wrappers.pop())) { if (isWNodeWrapper(wrapper)) { wrapper = getWNodeWrapper(wrapper.id) || wrapper; @@ -2428,11 +2437,11 @@ export function renderer(renderer: () => RenderResult): Renderer { if (wrapperChildren) { wrappers.push(...wrapperChildren); } - if (isBodyWrapper(wrapper)) { - bodyIds.push(wrapper.id); - } else if (bodyIds.indexOf(wrapper.parentId) !== -1) { + if (isBodyWrapper(wrapper) || isHeadWrapper(wrapper)) { + specialIds.push(wrapper.id); + } else if (specialIds.indexOf(wrapper.parentId) !== -1) { if (isWNodeWrapper(wrapper) || isVirtualWrapper(wrapper)) { - bodyIds.push(wrapper.id); + specialIds.push(wrapper.id); } else if (wrapper.domNode && wrapper.domNode.parentNode) { wrapper.domNode.parentNode.removeChild(wrapper.domNode); } diff --git a/tests/core/unit/vdom.tsx b/tests/core/unit/vdom.tsx index 68cd8c367..537f1a0cc 100644 --- a/tests/core/unit/vdom.tsx +++ b/tests/core/unit/vdom.tsx @@ -4850,6 +4850,258 @@ jsdomDescribe('vdom', () => { }); }); + describe('head node', () => { + let root = document.createElement('div'); + beforeEach(() => { + root = document.createElement('div'); + document.body.appendChild(root); + }); + + afterEach(() => { + document.body.removeChild(root); + }); + + it('can attach a node to the head', () => { + let show = true; + const factory = create({ invalidator }); + const App = factory(function App({ middleware: { invalidator } }) { + return v('div', [ + v('button', { + onclick: () => { + show = !show; + invalidator(); + } + }), + v('head', [show ? v('div', { id: 'my-head-node-1' }, ['My head Div 1']) : null]), + v('head', [show ? v('div', { id: 'my-head-node-2' }, ['My head Div 2']) : null]) + ]); + }); + const r = renderer(() => w(App, {})); + r.mount({ domNode: root }); + let headNodeOne = document.getElementById('my-head-node-1')!; + assert.isOk(headNodeOne); + assert.strictEqual(headNodeOne.outerHTML, '
My head Div 1
'); + assert.strictEqual(headNodeOne.parentNode, document.head); + assert.isNull(root.querySelector('#my-head-node-1')); + let headNodeTwo = document.getElementById('my-head-node-2')!; + assert.isOk(headNodeTwo); + assert.strictEqual(headNodeTwo.outerHTML, '
My head Div 2
'); + assert.strictEqual(headNodeTwo.parentNode, document.head); + assert.isNull(root.querySelector('#my-head-node-2')); + sendEvent(root.childNodes[0].childNodes[0] as Element, 'click'); + resolvers.resolve(); + headNodeOne = document.getElementById('my-head-node-1')!; + assert.isNull(headNodeOne); + assert.isNull(root.querySelector('#my-head-node-1')); + headNodeTwo = document.getElementById('my-head-node-2')!; + assert.isNull(headNodeTwo); + assert.isNull(root.querySelector('#my-head-node-2')); + }); + + it('can attach head and have widgets inserted nodes that are positioned after the head', () => { + const factory = create({ icache }); + const Button = factory(function Button({ children }) { + return ( +
+ +
+ ); + }); + const Head = factory(function Button({ children }) { + return ( + +
{children()}
+ + ); + }); + const App = factory(function App({ middleware }) { + const open = middleware.icache.getOrSet('open', false); + return ( +
+
first
+ {open && } + {open && Head} +
+ +
+
+ ); + }); + + const r = renderer(() => w(App, {})); + r.mount({ domNode: root }); + (root as any).children[0].children[1].children[0].click(); + resolvers.resolve(); + assert.strictEqual( + root.innerHTML, + '
first
' + ); + const headNode = document.getElementById('head-node'); + assert.isNotNull(headNode); + assert.strictEqual(headNode!.outerHTML, '
Head
'); + }); + + it('should detach nested head nodes from dom', () => { + let doShow: any; + + class A extends WidgetBase { + render() { + return v('div', [v('head', [v('span', { classes: ['head-span'] }, ['and im in the head!'])])]); + } + } + + class App extends WidgetBase { + private renderWidget = false; + + constructor() { + super(); + doShow = () => { + this.renderWidget = !this.renderWidget; + this.invalidate(); + }; + } + + protected render() { + return v('div', [this.renderWidget && w(A, {})]); + } + } + + const r = renderer(() => w(App, {})); + r.mount({ domNode: root }); + + let results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 1); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 1); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + }); + + it('should detach widgets nested in a head tag', () => { + let doShow: any; + + class A extends WidgetBase { + render() { + return v('div', [v('head', [w(B, {})])]); + } + } + + class B extends WidgetBase { + render() { + return v('span', { classes: ['head-span'] }, ['and im in the head!!']); + } + } + + class App extends WidgetBase { + private show = true; + + constructor() { + super(); + doShow = () => { + this.show = !this.show; + this.invalidate(); + }; + } + + protected render() { + return v('div', [this.show && w(A, {})]); + } + } + + const r = renderer(() => w(App, {})); + r.mount({ domNode: root }); + + let results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 1); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 1); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + }); + + it('should detach virtual nodes nested in a head tag', () => { + let doShow: any; + + class A extends WidgetBase { + render() { + return v('div', [ + v('head', [v('virtual', [v('span', { classes: ['head-span'] }, ['and im in the head!!'])])]) + ]); + } + } + + class App extends WidgetBase { + private show = true; + + constructor() { + super(); + doShow = () => { + this.show = !this.show; + this.invalidate(); + }; + } + + protected render() { + return v('div', [this.show && w(A, {})]); + } + } + + const r = renderer(() => w(App, {})); + r.mount({ domNode: root }); + + let results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 1); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 1); + doShow(); + resolvers.resolveRAF(); + resolvers.resolveRAF(); + results = document.querySelectorAll('.head-span'); + assert.lengthOf(results, 0); + }); + }); + describe('virtual node', () => { it('can use a virtual node', () => { const [Widget, meta] = getWidget(v('virtual', [v('div', ['one', 'two', v('div', ['three'])])]));