Skip to content

Commit

Permalink
Implements RFC #60 (Component Unification)
Browse files Browse the repository at this point in the history
emberjs/rfcs#60

This commit implements the proposed semantics for angle-bracket
components. The TLDR is that a component’s template represents its
“outerHTML” rather than its “innerHTML”, which makes it easier to
configure the element itself using templating constructs.

Some salient points:

1. If there is a single root element, the attributes from the invocation
   are copied onto the root element.
2. The invocation’s attributes “win out” over the attributes from the
   root element in the component’s layout.
3. Classes are merged. If the invocation says `class=“a”` and the root
   element says `class=“b”`, the result is `class=“a b”`. The rationale
   is that when you say `class=“a”`, you are really saying “add the `a`
   class to this element”.

A few sticky issues:

1. If the root element for the `my-foo` component is `<my-foo>`, we
   insert an element with tag name of `my-foo`. While this is intuitive,
   note that in all other cases, `<my-foo>` represents an invocation of
   the component. In the root position, that makes no sense, since it
   would inevitably produce a stack overflow.
   a. This “identity element” case makes it idiomatic to reflect the
      name of the component onto the DOM.
   b. Unfortunately, when we compile a template, we do not yet know
      what component that template is used for, and, indeed, whether it
      is even a template for a component at all.
   c. To capture the semantic differences, we do a bit of analysis at
      compile time (to determine *candidates* for top-level elements),
      and use runtime information (component invocation style and
      the name of the component looked up in the container) to
      disambiguate between a component’s element and an invocation of
      another component.
2. If the root element for the `my-foo` component is a regular HTML
   element, we use the `attachAttributes` functionality of HTMLBars to
   attach the attributes that the component was invoked with onto the
   root element.
3. In general, it is important that changes to attributes do not result
   in a change to the identity of the element that they are contained
   in. To achieve this, we reused much of the infrastructure built in
   `buildComponentTemplate`.

The consequence of (1) and (2) above is that the semantics are always
“a component’s layout represents its outerHTML”, even though the
implementation is quite different depending on whether the root element
is the same-named component or not.
  • Loading branch information
tomdale committed Jun 6, 2015
1 parent b155167 commit 3cd856a
Show file tree
Hide file tree
Showing 9 changed files with 328 additions and 63 deletions.
50 changes: 26 additions & 24 deletions packages/ember-htmlbars/lib/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import lookupHelper from "ember-htmlbars/hooks/lookup-helper";
import hasHelper from "ember-htmlbars/hooks/has-helper";
import invokeHelper from "ember-htmlbars/hooks/invoke-helper";
import element from "ember-htmlbars/hooks/element";
import attributes from "ember-htmlbars/hooks/attributes";

import helpers from "ember-htmlbars/helpers";
import keywords, { registerKeyword } from "ember-htmlbars/keywords";
Expand All @@ -37,30 +38,31 @@ var emberHooks = merge({}, hooks);
emberHooks.keywords = keywords;

merge(emberHooks, {
linkRenderNode: linkRenderNode,
createFreshScope: createFreshScope,
bindShadowScope: bindShadowScope,
bindSelf: bindSelf,
bindScope: bindScope,
bindLocal: bindLocal,
updateSelf: updateSelf,
getRoot: getRoot,
getChild: getChild,
getValue: getValue,
getCellOrValue: getCellOrValue,
subexpr: subexpr,
concat: concat,
cleanupRenderNode: cleanupRenderNode,
destroyRenderNode: destroyRenderNode,
willCleanupTree: willCleanupTree,
didCleanupTree: didCleanupTree,
didRenderNode: didRenderNode,
classify: classify,
component: component,
lookupHelper: lookupHelper,
hasHelper: hasHelper,
invokeHelper: invokeHelper,
element: element
linkRenderNode,
createFreshScope,
bindShadowScope,
bindSelf,
bindScope,
bindLocal,
updateSelf,
getRoot,
getChild,
getValue,
getCellOrValue,
subexpr,
concat,
cleanupRenderNode,
destroyRenderNode,
willCleanupTree,
didCleanupTree,
didRenderNode,
classify,
component,
lookupHelper,
hasHelper,
invokeHelper,
element,
attributes
});

import debuggerKeyword from "ember-htmlbars/keywords/debugger";
Expand Down
50 changes: 50 additions & 0 deletions packages/ember-htmlbars/lib/hooks/attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, internal } from "htmlbars-runtime";

export default function attributes(morph, env, scope, template, parentNode, visitor) {
let state = morph.state;
let block = state.block;

if (!block) {
let element = findRootElement(parentNode);
if (!element) { return; }

normalizeClassStatement(template.statements, element);

template.element = element;
block = morph.state.block = internal.blockFor(render, template, { scope });
}

block(env, [], undefined, morph, undefined, visitor);
}

function normalizeClassStatement(statements, element) {
let className = element.getAttribute('class');
if (!className) { return; }

for (let i=0, l=statements.length; i<l; i++) {
let statement = statements[i];

if (statement[1] === 'class') {
statement[2][2].unshift(className);
}
}
}

function findRootElement(parentNode) {
let node = parentNode.firstChild;
let found = null;

while (node) {
if (node.nodeType === 1) {
// found more than one top-level element, so there is no "root element"
if (found) { return null; }
found = node;
}
node = node.nextSibling;
}

let className = found && found.getAttribute('class');
if (!className || className.split(' ').indexOf('ember-view') === -1) {
return found;
}
}
50 changes: 36 additions & 14 deletions packages/ember-htmlbars/lib/hooks/component.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ComponentNodeManager from "ember-htmlbars/node-managers/component-node-manager";
import buildComponentTemplate from "ember-views/system/build-component-template";

export default function componentHook(renderNode, env, scope, _tagName, params, attrs, templates, visitor) {
var state = renderNode.state;
Expand All @@ -11,25 +12,46 @@ export default function componentHook(renderNode, env, scope, _tagName, params,

let tagName = _tagName;
let isAngleBracket = false;
let isTopLevel;

if (tagName.charAt(0) === '<') {
tagName = tagName.slice(1, -1);
let angles = tagName.match(/^(@?)<(.*)>$/);

if (angles) {
tagName = angles[2];
isAngleBracket = true;
isTopLevel = !!angles[1];
}

var parentView = env.view;

var manager = ComponentNodeManager.create(renderNode, env, {
tagName,
params,
attrs,
parentView,
templates,
isAngleBracket,
parentScope: scope
});

state.manager = manager;
if (!isTopLevel || tagName !== env.view.tagName) {
var manager = ComponentNodeManager.create(renderNode, env, {
tagName,
params,
attrs,
parentView,
templates,
isAngleBracket,
isTopLevel,
parentScope: scope
});

state.manager = manager;
manager.render(env, visitor);
} else {
let component = env.view;
let templateOptions = {
component,
isAngleBracket: true,
isComponentElement: true,
outerAttrs: scope.attrs,
parentScope: scope
};

let contentOptions = { templates, scope };

let { block } = buildComponentTemplate(templateOptions, attrs, contentOptions);
block(env, [], undefined, renderNode, scope, visitor);
}

manager.render(env, visitor);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ ComponentNodeManager.create = function(renderNode, env, options) {

extractPositionalParams(renderNode, component, params, attrs);

var results = buildComponentTemplate(
let results = buildComponentTemplate(
{ layout, component, isAngleBracket }, attrs, { templates, scope: parentScope }
);

Expand Down
116 changes: 103 additions & 13 deletions packages/ember-htmlbars/tests/integration/component_invocation_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -818,25 +818,115 @@ QUnit.module('component - invocation (angle brackets)', {
}
});

QUnit.test('non-block without properties', function() {
QUnit.test('non-block without properties replaced with a fragment when the content is just text', function() {
registry.register('template:components/non-block', compile('In layout'));

view = appendViewFor('<non-block />');

equal(view.$().html(), 'In layout', "Just the fragment was used");
});

QUnit.test('non-block without properties replaced with a fragment when the content is multiple elements', function() {
registry.register('template:components/non-block', compile('<div>This is a</div><div>fragment</div>'));

view = appendViewFor('<non-block />');

equal(view.$().html(), '<div>This is a</div><div>fragment</div>', "Just the fragment was used");
});

QUnit.test('non-block without properties replaced with a div', function() {
// The whitespace is added intentionally to verify that the heuristic is not "a single node" but
// rather "a single non-whitespace, non-comment node"
registry.register('template:components/non-block', compile(' <div>In layout</div> '));

view = appendViewFor('<non-block />');

equal(view.$().text(), ' In layout ');
ok(view.$().html().match(/^ <div id="[^"]*" class="ember-view">In layout<\/div> $/), "The root element has gotten the default class and ids");
ok(view.$('div.ember-view[id]').length === 1, "The div became an Ember view");

run(view, 'rerender');

equal(view.$().text(), ' In layout ');
ok(view.$().html().match(/^ <div id="[^"]*" class="ember-view">In layout<\/div> $/), "The root element has gotten the default class and ids");
ok(view.$('div.ember-view[id]').length === 1, "The non-block tag name was used");
});

QUnit.test('non-block without properties replaced with identity element', function() {
registry.register('template:components/non-block', compile('<non-block such="{{attrs.stability}}">In layout</non-block>'));

view = appendViewFor('<non-block stability={{view.stability}} />', {
stability: "stability"
});

let node = view.$()[0];
equal(view.$().text(), 'In layout');
ok(view.$().html().match(/^<non-block id="[^"]*" such="stability" class="ember-view">In layout<\/non-block>$/), "The root element has gotten the default class and ids");
ok(view.$('non-block.ember-view[id][such=stability]').length === 1, "The non-block tag name was used");

run(() => view.set('stability', 'stability!'));

strictEqual(view.$()[0], node, "the DOM node has remained stable");
equal(view.$().text(), 'In layout');
ok(view.$('non-block.ember-view').length === 1, "The non-block tag name was used");
ok(view.$().html().match(/^<non-block id="[^"]*" such="stability!" class="ember-view">In layout<\/non-block>$/), "The root element has gotten the default class and ids");
});

QUnit.test('non-block with class replaced with a div merges classes', function() {
registry.register('template:components/non-block', compile('<div class="inner-class" />'));

view = appendViewFor('<non-block class="{{view.outer}}" />', {
outer: 'outer'
});

equal(view.$('div').attr('class'), "inner-class outer ember-view", "the classes are merged");

run(() => view.set('outer', 'new-outer'));

equal(view.$('div').attr('class'), "inner-class new-outer ember-view", "the classes are merged");
});

QUnit.test('non-block with class replaced with a identity element merges classes', function() {
registry.register('template:components/non-block', compile('<non-block class="inner-class" />'));

view = appendViewFor('<non-block class="{{view.outer}}" />', {
outer: 'outer'
});

equal(view.$('non-block').attr('class'), "inner-class outer ember-view", "the classes are merged");

run(() => view.set('outer', 'new-outer'));

equal(view.$('non-block').attr('class'), "inner-class new-outer ember-view", "the classes are merged");
});

QUnit.test('non-block rendering a fragment', function() {
registry.register('template:components/non-block', compile('<p>{{attrs.first}}</p><p>{{attrs.second}}</p>'));

view = appendViewFor('<non-block first={{view.first}} second={{view.second}} />', {
first: 'first1',
second: 'second1'
});

equal(view.$().html(), '<p>first1</p><p>second1</p>', "No wrapping element was created");

run(view, 'setProperties', {
first: 'first2',
second: 'second2'
});

equal(view.$().html(), '<p>first2</p><p>second2</p>', "The fragment was updated");
});

QUnit.test('block without properties', function() {
registry.register('template:components/with-block', compile('In layout - {{yield}}'));
registry.register('template:components/with-block', compile('<with-block>In layout - {{yield}}</with-block>'));

view = appendViewFor('<with-block>In template</with-block>');

equal(view.$('with-block.ember-view').text(), "In layout - In template", "Both the layout and template are rendered");
});

QUnit.test('non-block with properties on attrs', function() {
registry.register('template:components/non-block', compile('In layout'));
registry.register('template:components/non-block', compile('<non-block>In layout</non-block>'));

view = appendViewFor('<non-block static-prop="static text" concat-prop="{{view.dynamic}} text" dynamic-prop={{view.dynamic}} />', {
dynamic: "dynamic"
Expand All @@ -854,7 +944,7 @@ QUnit.test('non-block with properties on attrs', function() {
QUnit.test('attributes are not installed on the top level', function() {
let component;

registry.register('template:components/non-block', compile('In layout - {{attrs.text}}'));
registry.register('template:components/non-block', compile('<non-block>In layout - {{attrs.text}}</non-block>'));
registry.register('component:non-block', Component.extend({
text: null,
dynamic: null,
Expand Down Expand Up @@ -888,7 +978,7 @@ QUnit.test('attributes are not installed on the top level', function() {

QUnit.test('non-block with properties on attrs and component class', function() {
registry.register('component:non-block', Component.extend());
registry.register('template:components/non-block', compile('In layout - someProp: {{attrs.someProp}}'));
registry.register('template:components/non-block', compile('<non-block>In layout - someProp: {{attrs.someProp}}</non-block>'));

view = appendViewFor('<non-block someProp="something here" />');

Expand All @@ -909,7 +999,7 @@ QUnit.test('rerendering component with attrs from parent', function() {
}
}));

registry.register('template:components/non-block', compile('In layout - someProp: {{attrs.someProp}}'));
registry.register('template:components/non-block', compile('<non-block>In layout - someProp: {{attrs.someProp}}</non-block>'));

view = appendViewFor('<non-block someProp={{view.someProp}} />', {
someProp: 'wycats'
Expand All @@ -935,7 +1025,7 @@ QUnit.test('rerendering component with attrs from parent', function() {
});

QUnit.test('block with properties on attrs', function() {
registry.register('template:components/with-block', compile('In layout - someProp: {{attrs.someProp}} - {{yield}}'));
registry.register('template:components/with-block', compile('<with-block>In layout - someProp: {{attrs.someProp}} - {{yield}}</with-block>'));

view = appendViewFor('<with-block someProp="something here">In template</with-block>');

Expand All @@ -946,7 +1036,7 @@ QUnit.test('moduleName is available on _renderNode when a layout is present', fu
expect(1);

var layoutModuleName = 'my-app-name/templates/components/sample-component';
var sampleComponentLayout = compile('Sample Component - {{yield}}', {
var sampleComponentLayout = compile('<sample-component>Sample Component - {{yield}}</sample-component>', {
moduleName: layoutModuleName
});
registry.register('template:components/sample-component', sampleComponentLayout);
Expand Down Expand Up @@ -985,7 +1075,7 @@ QUnit.test('moduleName is available on _renderNode when no layout is present', f
});

QUnit.test('parameterized hasBlock default', function() {
registry.register('template:components/check-block', compile('{{#if (hasBlock)}}Yes{{else}}No{{/if}}'));
registry.register('template:components/check-block', compile('<check-block>{{#if (hasBlock)}}Yes{{else}}No{{/if}}</check-block>'));

view = appendViewFor('<check-block id="expect-yes-1" /> <check-block id="expect-yes-2"></check-block>');

Expand All @@ -994,7 +1084,7 @@ QUnit.test('parameterized hasBlock default', function() {
});

QUnit.test('non-expression hasBlock ', function() {
registry.register('template:components/check-block', compile('{{#if hasBlock}}Yes{{else}}No{{/if}}'));
registry.register('template:components/check-block', compile('<check-block>{{#if hasBlock}}Yes{{else}}No{{/if}}</check-block>'));

view = appendViewFor('<check-block id="expect-yes-1" /> <check-block id="expect-yes-2"></check-block>');

Expand All @@ -1003,7 +1093,7 @@ QUnit.test('non-expression hasBlock ', function() {
});

QUnit.test('parameterized hasBlockParams', function() {
registry.register('template:components/check-params', compile('{{#if (hasBlockParams)}}Yes{{else}}No{{/if}}'));
registry.register('template:components/check-params', compile('<check-params>{{#if (hasBlockParams)}}Yes{{else}}No{{/if}}</check-params>'));

view = appendViewFor('<check-params id="expect-no"/> <check-params id="expect-yes" as |foo|></check-params>');

Expand All @@ -1012,7 +1102,7 @@ QUnit.test('parameterized hasBlockParams', function() {
});

QUnit.test('non-expression hasBlockParams', function() {
registry.register('template:components/check-params', compile('{{#if hasBlockParams}}Yes{{else}}No{{/if}}'));
registry.register('template:components/check-params', compile('<check-params>{{#if hasBlockParams}}Yes{{else}}No{{/if}}</check-params>'));

view = appendViewFor('<check-params id="expect-no" /> <check-params id="expect-yes" as |foo|></check-params>');

Expand Down
Loading

0 comments on commit 3cd856a

Please sign in to comment.