Skip to content

Commit

Permalink
Merge pull request #331 from Robert-Frampton/portal
Browse files Browse the repository at this point in the history
Skip portal rendering during SSR
  • Loading branch information
jbalsas authored Jan 12, 2018
2 parents ac2ecac + 6bd1caa commit 13530ca
Show file tree
Hide file tree
Showing 11 changed files with 396 additions and 4 deletions.
45 changes: 45 additions & 0 deletions packages/metal-component/src/Component.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ class Component extends EventEmitter {
*/
this.initialConfig_ = config || {};

/**
* Indicates whether the component should be rendered as a Portal, outside
* of the parent component.
* @type {string|Element|boolean}
*/
this.portalElement = null;

/**
* Whether the element was rendered.
* @type {boolean}
Expand All @@ -154,6 +161,8 @@ class Component extends EventEmitter {
this.setUpDataManager_();
this.setUpSyncUpdates_();

this.setUpPortal_(this.initialConfig_.portalElement);

this.on('stateWillChange', this.handleStateWillChange_);
this.on('stateChanged', this.handleComponentStateChanged_);
this.on('eventsChanged', this.onEventsChanged_);
Expand Down Expand Up @@ -658,6 +667,42 @@ class Component extends EventEmitter {
);
}

/**
* Overwrites element property if portalElement is passed. Creates
* a nested placeholder so that portalElement is not removed from the
* DOM when component first renders. When portalElement is equal to true,
* component is appeneded to the body.
*
* @param {string|Element|boolean} portalElement
*/
setUpPortal_(portalElement) {
if (
!isElement(portalElement) &&
!isString(portalElement) &&
!isBoolean(portalElement)
) {
return;
} else if (isBoolean(portalElement) && portalElement) {
portalElement = 'body';
}

if (isServerSide()) {
this.portalElement = true;
return;
}

portalElement = toElement(portalElement);

if (portalElement) {
const placeholder = document.createElement('div');

portalElement.appendChild(placeholder);

this.element = placeholder;
this.portalElement = portalElement;
}
}

/**
* Sets up the component's renderer.
* @protected
Expand Down
54 changes: 54 additions & 0 deletions packages/metal-component/test/Component.js
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,60 @@ describe('Component', function() {
sinon.assert.calledWith(hookStub, comp);
});

it('should render component to portalElement', function() {
const portalElement = document.createElement('span');

comp = Component.render(Component, {
portalElement,
});

assert.strictEqual(comp.element.parentNode, portalElement);
assert.strictEqual(comp.portalElement, portalElement);
});

it('should set portalElement from selector', function() {
const portalElement = document.createElement('div');
portalElement.setAttribute('id', 'foo');

document.body.appendChild(portalElement);

comp = Component.render(Component, {
portalElement: '#foo',
});

assert.strictEqual(comp.element.parentNode, portalElement);
});

it('should set portalElement to body when set to true', function() {
const parentElement = document.createElement('span');

document.body.appendChild(parentElement);

comp = Component.render(
Component,
{
portalElement: true,
},
parentElement
);

assert.strictEqual(comp.element.parentNode, document.body);
});

it('should detach component from DOM when portalElement is passed', function() {
const portalElement = document.createElement('span');

comp = Component.render(Component, {
portalElement,
});

assert.ok(comp.inDocument);

comp.dispose();

assert.ok(!comp.inDocument);
});

function createCustomComponentClass(rendererContentOrFn) {
class CustomComponent extends Component {}
CustomComponent.RENDERER = createCustomRenderer(rendererContentOrFn);
Expand Down
4 changes: 3 additions & 1 deletion packages/metal-incremental-dom/src/IncrementalDomRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ class IncrementalDomRenderer extends ComponentRenderer.constructor {
for (let i = 0; i < data.childComponents.length; i++) {
const child = data.childComponents[i];
if (!child.isDisposed()) {
child.element = null;
if (!child.portalElement) {
child.element = null;
}
child.dispose();
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/metal-incremental-dom/src/cleanup/unused.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export function disposeUnused() {
if (!comp.isDisposed() && !getData(comp).parent) {
// Don't let disposing cause the element to be removed, since it may
// be currently being reused by another component.
comp.element = null;
if (!comp.portalElement) {
comp.element = null;
}
comp.dispose();
}
}
Expand Down
10 changes: 9 additions & 1 deletion packages/metal-incremental-dom/src/render/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
isDef,
isDefAndNotNull,
isFunction,
isServerSide,
isString,
object,
} from 'metal';
Expand Down Expand Up @@ -537,7 +538,14 @@ function renderSubComponent_(tagOrCtor, config, owner) {
config.key = parentData.config.key;
}

comp.getRenderer().renderInsidePatch(comp);
if (comp.portalElement && isServerSide()) {
return comp;
}

if (!comp.portalElement) {
comp.getRenderer().renderInsidePatch(comp);
}

if (!comp.wasRendered) {
comp.renderComponent();
}
Expand Down
213 changes: 213 additions & 0 deletions packages/metal-incremental-dom/test/IncrementalDomRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3331,4 +3331,217 @@ describe('IncrementalDomRenderer', function() {
const config = IncrementalDomRenderer.getConfig(component);
assert.strictEqual(getData(component).config, config);
});

describe('Portals', function() {
beforeEach(function() {
document.body.innerHTML = '';
});

it('should render sub components to defined portalElement', function() {
const portalElement = createPortalElement();

class TestChildComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Child');
IncDom.elementClose('div');
}
}
TestChildComponent.RENDERER = IncrementalDomRenderer;

class TestComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Parent');
IncDom.elementOpen(
TestChildComponent,
null,
null,
'ref',
'child',
'portalElement',
portalElement
);
IncDom.elementClose(TestChildComponent);
IncDom.elementClose('div');
}
}
TestComponent.RENDERER = IncrementalDomRenderer;

component = new TestComponent();

assert.strictEqual(
document.body.innerHTML,
'<div id="host"><div>Child</div></div><div>Parent</div>'
);
});

it('should update sub components that have a defined portalElement', function(
done
) {
const portalElement = createPortalElement();

class TestChildComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Child: ' + this.foo);
IncDom.elementClose('div');
}
}
TestChildComponent.RENDERER = IncrementalDomRenderer;
TestChildComponent.STATE = {
foo: {},
};

class TestComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Parent: ' + this.foo);
IncDom.elementOpen(
TestChildComponent,
null,
null,
'ref',
'child',
'foo',
this.foo,
'portalElement',
portalElement
);
IncDom.elementClose(TestChildComponent);
IncDom.elementClose('div');
}
}
TestComponent.RENDERER = IncrementalDomRenderer;
TestComponent.STATE = {
foo: {
value: 'bar',
},
};

component = new TestComponent();

assert.strictEqual(
document.body.innerHTML,
'<div id="host"><div>Child: bar</div></div><div>Parent: bar</div>'
);

component.foo = 'baz';
component.refs.child.once('stateSynced', function() {
assert.strictEqual(
document.body.innerHTML,
'<div id="host"><div>Child: baz</div></div><div>Parent: baz</div>'
);
done();
});
});

it('should dispose sub components with a portalElement when parent is disposed', function() {
const portalElement = createPortalElement();

class TestChildComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Child');
IncDom.elementClose('div');
}
}
TestChildComponent.RENDERER = IncrementalDomRenderer;

class TestComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Parent');
IncDom.elementOpen(
TestChildComponent,
null,
null,
'ref',
'child',
'portalElement',
portalElement
);
IncDom.elementClose(TestChildComponent);
IncDom.elementClose('div');
}
}
TestComponent.RENDERER = IncrementalDomRenderer;

component = new TestComponent();

assert.strictEqual(
document.body.innerHTML,
'<div id="host"><div>Child</div></div><div>Parent</div>'
);

component.dispose();

assert.strictEqual(document.body.innerHTML, '<div id="host"></div>');
});

it('should dispose sub components with portalElement when removed by parent component', function(
done
) {
const portalElement = createPortalElement();

class TestChildComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Child');
IncDom.elementClose('div');
}
}
TestChildComponent.RENDERER = IncrementalDomRenderer;

class TestComponent extends Component {
render() {
IncDom.elementOpen('div');
IncDom.text('Parent');
if (!this.remove) {
IncDom.elementVoid(
TestChildComponent,
null,
null,
'ref',
'child',
'portalElement',
portalElement
);
}
IncDom.elementClose('div');
}
}
TestComponent.RENDERER = IncrementalDomRenderer;
TestComponent.STATE = {
remove: {
value: false,
},
};

component = new TestComponent();

assert.strictEqual(
document.body.innerHTML,
'<div id="host"><div>Child</div></div><div>Parent</div>'
);

component.remove = true;

component.refs.child.once('disposed', function() {
assert.strictEqual(
document.body.innerHTML,
'<div id="host"></div><div>Parent</div>'
);

done();
});
});
});
});

function createPortalElement() {
const portalElement = document.createElement('div');
portalElement.setAttribute('id', 'host');
document.body.appendChild(portalElement);
return portalElement;
}
Loading

0 comments on commit 13530ca

Please sign in to comment.