Skip to content

Skip portal rendering during SSR #331

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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