Skip to content
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

Bind service and state component #6449

Merged
merged 43 commits into from
Dec 14, 2016

Conversation

dreamofabear
Copy link

@dreamofabear dreamofabear commented Dec 2, 2016

Partial for #6199.

  • First pass on Bind service and new <amp-bind-state> component
  • Support for changing src and a few other attributes for builtins (amp-img, amp-video)
  • Adds AMP.setBindState(key=value) action for now (can be changed later)

TODO:

  • Bind's runtime validation of evaluated attribute values, etc.
  • Allow Bind syntax in validator rules (ideally same solution as above)
  • Performance monitoring (chunking slow DOM scans or digests)
  • Support for extended components (amp-carousel, etc.)

request.newHeight = newHeight;
request.newWidth = newWidth;
request.newHeight = newHeight || request.newHeight;
request.newWidth = newWidth || request.newWidth;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This avoids two resources#changeSize methods from clobbering each other. Without this change:

changeSize(el, 100, undefined)
changeSize(el, undefined, 100)

el won't have its height changed to 100 since the second call's newHeight value of undefined will override the first call.

Perhaps I should move this to a separate PR since it's a somewhat scary change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely. We also need to consider newHeight = 0, and that the combined might fail because it violates a heights rule but the width only should have succeeded.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll move this to a separate PR.


const id = this.element.id;
if (!id) {
user().error(TAG, 'Element must have an id.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}

if (id && json) {
const state = Object.create(null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map helper.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, map just wraps Object.create(null). Why is adding an import and function call for 1 LOC preferable here?

scanForBindings_(body) {
const bindings = [];
const elements = body.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be very expensive. Can we chunk it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly possible. One nice thing of Bind is that we can chunk the DOM scan as well as digests. I'd like to leave performance tuning (including for binding expressions as discussed) to a later stage. Added a TODO here.

return null;
}
const name = attribute.name;
if (name.charAt(0) === '[' && name.charAt(name.length - 1) === ']') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use name[0] and name[name.length - 1]

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. Done.

} else {
const attribute = element.getAttribute(property);
if (typeof expectedValue === 'boolean') {
initialValue = !!attribute;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they use attr="false", this will never verify. Intended?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not?

  • [attr]="false" will expect attr to be a toggled-off boolean attribute
  • [attr]="'false'" will expect attr to have the string value "false"

if (property === 'text') {
element.textContent = newValue;
} else if (property === 'class') {
element.classList = newValue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will have problems setting the class on SVG elements.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point; added a TODO. The validator should disallow [class] binding on SVG elements, which should be aligned with runtime binding sanitation.

* @private
*/
sanitizeAttribute_(value) {
if (typeof value === 'string') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once it gets to #setAttribute, the value will be cast to a string. So, {toString() { return 'javascript:alert("")'; } would pass this check but still inject the XSS.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be possible since Bind does not allow arbitrary function declarations (and I'm not sure that code fragment specifically works as an exploit). Angular did have similar exploits though, so good thinking. This is definitely something the security review will cover.

this.vsync_ = vsyncFor(ampdoc.win);

/** @const {!Array<string>} */
this.protocolWhitelist_ = ['http', 'https'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make this a prototype-less map.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed as I rethink how best to validate attribute values.

*/
sanitizeAttribute_(value) {
if (typeof value === 'string') {
const split = value.split(':');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This'll have to pass security review, but might be able specialize our protocol search a bit more:

const protocol = `/^([A-Za-z]+([A-Za-z+.-])*):/`.match(value);
if (protocol && !this.protocolWhitelist_[protocol[0]]) {
  // unsafe
}
// safe

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed for now as I rethink how runtime validation will work.

@dreamofabear dreamofabear changed the title [WIP] Bind service and state component Bind service and state component Dec 5, 2016
@dreamofabear
Copy link
Author

/to @cramforce @dvoytenko /cc @aghassemi

Copy link
Author

@dreamofabear dreamofabear left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! Working on tests for bind-impl.js now.

@@ -41,6 +41,10 @@ export function installVideo(win) {

/** @private {?Element} */
this.video_ = null;

/** @private {!Array<string>} */
this.attributesToPropagateOnChange_ = ['poster', 'controls',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted.

@@ -38,6 +38,20 @@ export class AmpImg extends BaseElement {

/** @private {?../src/srcset.Srcset} */
this.srcset_ = null;

/** @private {!Array<string>} */
this.attributesToPropagate_ = ['alt', 'referrerpolicy', 'aria-label',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
</style>

<amp-bind-state id="myState">
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually had it as amp-state and AMP.setState originally, but felt it may be too broad. But since you brought it up, I'm happy to remove the "bind" verbiage. Done.

@@ -109,6 +109,7 @@
<script async custom-element="amp-ad" src="https://cdn.ampproject.org/v0/amp-ad-0.1.js"></script>
<script async custom-element="amp-anim" src="https://cdn.ampproject.org/v0/amp-anim-0.1.js"></script>
<script async custom-element="amp-audio" src="https://cdn.ampproject.org/v0/amp-audio-0.1.js"></script>
<script async custom-element="amp-bind" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"></script>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, removed.

* @private
*/
getName_() {
return 'AmpBindState ' +
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

/** @typedef {(null|boolean|string|number|Array|Object)} */
let BindExpressionResultDef;

export class Bind {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

* @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
*/
constructor(ampdoc) {
/** @const {!../../../src/service/ampdoc-impl.AmpDoc} */
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

measure_(state) {
for (let i = 0; i < this.bindings_.length; i++) {
const binding = this.bindings_[i];
state.results[i] = evaluateBindExpr(binding.expression, this.scope_);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done.

// TODO(choumx): Support objects for attributes.

if (property === 'text') {
element.textContent = newValue;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we ought to find and mutate the child text node instead? That may be more user friendly but I'm a bit concerned about performance implications.

Mutating textContent also doesn't trigger reflow which users will probably want. Any suggestions on how to handle that?

let match = true;

if (property === 'text') {
initialValue = element.textContent;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

const task = {measure: this.boundMeasure_, mutate: this.boundMutate_};
this.vsync_.run(task, {
results: [],
verifyOnly: opt_verifyOnly,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Let's !! this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. This is only a problem if called by a class that has type-checking disabled, right?

* element is the last result of `this.bindings_[i].expression`.
* @type {!Array<BindExpressionResultDef>}
*/
this.previousResults_ = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be on the BindingDef?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a bit cleaner. Done.

* @return {(string|boolean|number|null)}
*/
attributeValueOf_(value) {
if (typeof value === 'string' || typeof value === 'boolean'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can store the value of typeof into a variable.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually had it that way originally but couldn't get it to work with Closure's type checker. I'll give it another try.

Also changed to use a hash as a minor optimization.

} else {
const attribute = element.getAttribute(property);
if (typeof expectedValue === 'boolean') {
initialValue = !!attribute;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continuing:

You're casting the value from #getAttribute, which in this case would be:

  • "false", we expect boolean. But !!"false" === true.
  • "'false'". This one works fine, because we expect a string.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch. Added a unit test for this. Thanks!

this.srcset_ = srcsetFromElement(this.element);
this.updateImageSrc_();
} else if (this.img_ && this.attributesToPropagate_.indexOf(name) >= 0) {
this.propagateAttributes(name, this.img_);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at propagateAttributes's implementation, it will not propagate the attribute if element originally did not have it. This will become a problem when bind supports conditionally adding/removing attributes and not just changing them. (unless we plan to have a different callback for added/removed attributes)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point, Bind will have already called element.setAttribute(name, newValue) so this should still work for adding new attributes.

Good point about removing attributes though, added a new optional param to support that.

@@ -41,6 +41,10 @@ export function installVideo(win) {

/** @private {?Element} */
this.video_ = null;

/** @private {!Array<string>} */
this.attributesToPropagateOnChange_ = ['poster', 'controls',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skip controls for now, there is more to just propagating it. video-manager also needs to be able to handle videos dynamically losing controls which it does not handle right now.

<p [text]="foo">After clicking the button below, this will read 'foo'<p>
<p id="foo" [text]="foo + 'bar'">And this will read 'foobar'<p>
<p [text]="myState.myStateKey1">This will read 'myStateValue1'<p>
<button [disabled]="isButtonDisabled">This button will be disabled</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit worried "boolean" attributes. boolAttr=false is technically invalid in HTML5. ("The values "true" and "false" are not allowed on boolean attributes. To represent a false value, the attribute has to be omitted altogether.")

although disable=false works for buttons, it won't work for many of AMP specific attributes since we use hasAttribute to check for them (e.g. will actually loop).

Few options here:
1- We allow boolAttr=<true/false> in bind syntax but bind handles adding/removing it based on the boolean value (goes back to my first comment of how to let the component know about new added/removed attrs)

2- Have special bind syntax for bool attributes (e.g. [disabled]?="boolExpression")

3- Do both.

I personally think (1) is ok despite being against HTML5 recommendation, we just need to have bind handle adding/removing boolean attributes and let the component know ( I suggest a new callback separate from attributeChangedCallback, maybe attributeAddedRemovedCallback(bool added). @dvoytenko @cramforce

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged per comment below.

buildCallback() {
const TAG = this.getName_();

this.element.setAttribute('aria-hidden', 'true');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apply nodisplay layout to it instead? (otherwise it will create a line break as it is a block element)

Copy link
Author

@dreamofabear dreamofabear Dec 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you modify layout_ within the element subclass? I don't see any examples of this. It shouldn't cause a newline since we toggle it off before layout completes.


this.element.setAttribute('aria-hidden', 'true');

const id = this.element.id;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: const id = user().assert(this.element.id, '%s element must have an id.', TAG);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

layoutCallback() {
// Now that we are rendered, stop rendering the element to reduce
// resource consumption.
toggle(this.element, false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need layout callback all all? why not do toggle(this.element, false); in buildCallback remove layoutCallback and set renderOutsideViewport to return true;

Copy link
Author

@dreamofabear dreamofabear Dec 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we want amp-state to be available wherever it's located in the document. Done.

this.boundMutate_ = this.mutate_.bind(this);

this.ampdoc.whenBodyAvailable().then(body => {
this.bindings_ = this.scanForBindings_(body);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This alone is not enough and can't handle dynamic content (e.g. rendered via amp-mustache templates). Ideally we could use MutationObserver to run this on newly added DOM but aside from not being supported everywhere, I am not sure of its performance. Maybe a good compromise it to have the template service either run this on render of new templates or it triggers an event we can listen to here to scan again (I like the latter for looser coupling)

Copy link
Author

@dreamofabear dreamofabear Dec 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Current approach is to add support for other AMP extensions one-by-one and make adjustments as necessary.

it triggers an event we can listen to here to scan again

SGTM.

if (newValue === true) {
element.setAttribute(property, '');
} else if (newValue === false) {
element.removeAttribute(property);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per #6449 (comment), components need to be notified of this as well. So maybe element.attributeAddedRemoveCallback(bool added)? (you can ignore parts of that comment as I was advocating for what you already have here before I saw this)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current method can handle that with null representing nonexistence and '' representing an existing boolean attribute.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current method can handle it too as you mentioned but it is a bit of mental work to figure out if attribute was added/removed based on values of oldValue attributeValue. Either way, we are not calling attributeChangedCallback for boolean attributes currently.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, good catch! Fixed and added unit test.

if (element.classList.contains('-amp-element')) {
const resources = element.getResources();
if (property === 'width') {
user().assert(typeof attributeValue === 'number',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use isFiniteNumber from types.js

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good tip. Done.

* @param {BindExpressionResultDef|undefined} b
* @return {boolean}
*/
shallowEquals_(a, b) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dvoytenko: Didn't we create a jsonEquals that's just like this?

Copy link
Author

@dreamofabear dreamofabear Dec 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't find any methods named jsonEquals. Also for this method we only check the first-level children for performance reasons (Bind also doesn't care about children past first level).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually not aware of jsonEquals.

@@ -300,7 +301,8 @@ export class Bind {

if (property === 'text') {
initialValue = element.textContent;
match = (initialValue.trim() === expectedValue.trim());
match = (initialValue.trim() ===
Object.prototype.toString.call(expectedValue).trim());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

expectedValue = String(expectedValue);
match = (trim() === trim())

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if (typeof expectedValue === 'boolean') {
initialValue = !!attribute;
// Boolean attributes return values of either '' or null.
match = (expectedValue && initialValue === '')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should allow "true" and "false" as well, else we'd disallow expressions like [attr]="true".

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this is probably not a common use case and muddles the API a bit. Also, the expression would have to be [attr]="'true'", which evaluates to a string.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't allow toplevel boolean attributes? Never mind.

if (typeof value === 'string' || typeof value === 'boolean'
|| typeof value === 'number') {
const type = typeof(value);
if (this.attributeValueTypes_[type] !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for the !== undefined?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -802,6 +806,16 @@ export class BaseElement {
}

/**
* Called when an attribute's value changes.
* @param {!string} unusedName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just {string} - they are non-nullable by default.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -802,6 +806,16 @@ export class BaseElement {
}

/**
* Called when an attribute's value changes.
* @param {!string} unusedName
* @param {?string} unusedOldValue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain in docs the meaning of null values. newValue == null means the attribute was removed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done.

* @param {?string} unusedOldValue
* @param {?string} unusedNewValue
*/
attributeChangedCallback(unusedName, unusedOldValue, unusedNewValue) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the notion of bulk change is lost in this API. E.g. the system might accumulate N changes for this element and we'll call them uncorrelated one at a time. How do you see an implementation would be able to reduce amount of work? Would we use vsync? E.g.

attributeChangedCallback(...) {
  switch (name) {
    case 'src':
       this.src_ = newValue;
       break;
    case 'abc':
       this.stateAbc_ = newValue;
       break;
  }
  this.getVsync().mutate(() => this.updateUi_);
}

Is this how you see it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good point. Yea, internal vsync is probably the most robust (e.g. if services other than Bind mutate element attributes). I can also revisit this later and change it to pass a map of attributes to old/new values if that looks more attractive as Bind support rolls out.

@@ -36,6 +37,7 @@ const DEFAULT_METHOD_ = 'activate';
/** @const {!Object<string,!Array<string>>} */
const ELEMENTS_ACTIONS_MAP_ = {
'form': ['submit'],
'amp': ['setState'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do all capitals AMP.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -224,12 +226,22 @@ export class ActionService {
return;
}

if (action.actionInfo.target === 'AMP') {
if (action.actionInfo.method === 'setState') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should instead create an AMP object in src and register it with action service as a target. We have ActionService.installActionHandler for this purpose.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that all action targets are assumed to be Element type, so I would need to substantially refactor action-impl.js to do this. Should I proceed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine to defer. But we do need to do it very soon.

@@ -41,6 +47,17 @@ export class AmpImg extends BaseElement {
}

/** @override */
attributeChangedCallback(name, unusedOldValue, unusedNewValue) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, does attributeChangedCallback already assumes mutate context? If so, please document this at the API level.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it must be run in a vsync mutate. Do you see a problem if it isn't?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as it's documented well at the API level, it's fine.

this.srcset_ = srcsetFromElement(this.element);
this.updateImageSrc_();
} else if (this.img_ && ATTRIBUTES_TO_PROPAGATE.indexOf(name) >= 0) {
this.propagateAttributes(name, this.img_,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we ever NOT need to remove extra attributes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered the same. I'm not sure that all AMP extensions would not misbehave with this new functionality, so I'm being conservative with this new arg.

* @param {?string} oldValue
* @param {?string} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this method called? Or not yet?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually called by the browser (part of Custom Elements V1 API). In this PR, bind-impl.js also calls it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a better design to not align ourselves with the custom elements spec and instead call APIs in various lifecycle of the state changes.

We might need attributeChangedCallback, but I think it should only be used to translate those changes into state changes

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cramforce Could you elaborate on what you mean by that? Add specific APIs for each AMP extension like srcChangedCallback to be called by Bind? Or an API that supports bulk changes like Dima suggested?

Also note that the browser only calls attributeChangedCallback for attributes specified in observedAttributes, which is currently not used anywhere in AMP.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way: I don't necessarily want to block this PR on the discussion.

I think we need a doc (or a section in your existing design doc) explaining the lifecycle that happens on an attribute change.

I think the data flow is a bit off when we have these callbacks for attribute changes. I was thinking more of bundling all the attributes as state for a component and calling it when it changes with the request to redraw itself.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I'll add a section to the design doc about this.

@dvoytenko
Copy link
Contributor

@choumx Looks good. But let's checkin on Monday re:idea of media queries. I'd like to simply make sure we won't run into ourselves here.

}

if (typeof a === 'object') {
const keysA = a.keys();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object.keys()

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return true;
}

if (typeof a !== typeof b) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early exit for "is actually the same object?"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, moved the last clause to the top.

* @param {BindVsyncStateDef} state
* @private
*/
measure_(state) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you change the design to

  • move all processing of bindings into its own file
  • in a way that that file itself never touches the DOM (and has its own cache for expression results)
  • and produces a set of instructions with the sparse set of things to apply to the DOM
  • has an async interface

The goal would be that we can move all of it into a worker without major refactoring of this file.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved expression evaluation to new file bind-evaluator.js.

measure_(state) {
for (let i = 0; i < this.bindings_.length; i++) {
const binding = this.bindings_[i];
state.results[i] = evaluateBindExpr(binding.expression, this.scope_);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One easy speedup would be to cache the result of expressions per run. I think realistic docs will often have the same expression many times.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done and added unit test.

state[id] = json;

bindForDoc(this.getAmpDoc()).then(bind => {
bind.setState(state, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean there can only be one AmpState on the page?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, setState(state) merges state into existing data via Object#assign rather than replacing it.

@dvoytenko
Copy link
Contributor

LGTM from me. Deferring to @cramforce and @jridgewell for the final approval.

Copy link
Member

@cramforce cramforce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets get this in.

evaluate(scope) {
return new Promise(resolve => {
/** @type {!Object<string,*>} */
const output = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename to cache

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@dreamofabear dreamofabear merged commit d25c72e into ampproject:master Dec 14, 2016
@dreamofabear dreamofabear deleted the bind-component branch December 14, 2016 16:02
Lith pushed a commit to Lith/amphtml that referenced this pull request Dec 22, 2016
* initial commit for bind component/service

* make default value error more descriptive, only fire in dev mode

* rename some methods/vars

* don't strict equality check for default verification

* hack support for on='tap:setState'

* remove null result check for expr eval

* use vsync for digest mutation, add class and attr toggle

* first pass on amp-bind-state element

* add class change example

* add amp-img ex, fix dumb sanitize attr bug

* move state to separate element per design doc

* document methods in bind-impl.js

* properly handle boolean attributes

* add framework for reacting to attr changes, support size change, fix comments

* propagate attrs in amp-img

* move bind service to /extensions, skip digest for amp-bind-state

* clean up action-impl

* fix lint errors

* handle amp-bind not installed

* fix closure errors in compiled bind expr

* fix type annotations

* add amp-video support

* add amp-bind validator proto

* PR comments, more types, TODOs

* fix long line

* remove local assets in bind example

* fix typedef

* more type fixes

* fix lint error

* revert amp-video changes

* rename bind-state to state

* PR comments

* add unit tests

* check shallow equality during mutate, more unit tests

* PR comments

* nit fix

* call attr changed callback for bool attrs

* more PR comments

* lint and style fixes

* PR comments, class-ify bind-expr

* move expr eval to separate file

* fix lint and type errors

* PR comments
Lith pushed a commit to Lith/amphtml that referenced this pull request Dec 22, 2016
* initial commit for bind component/service

* make default value error more descriptive, only fire in dev mode

* rename some methods/vars

* don't strict equality check for default verification

* hack support for on='tap:setState'

* remove null result check for expr eval

* use vsync for digest mutation, add class and attr toggle

* first pass on amp-bind-state element

* add class change example

* add amp-img ex, fix dumb sanitize attr bug

* move state to separate element per design doc

* document methods in bind-impl.js

* properly handle boolean attributes

* add framework for reacting to attr changes, support size change, fix comments

* propagate attrs in amp-img

* move bind service to /extensions, skip digest for amp-bind-state

* clean up action-impl

* fix lint errors

* handle amp-bind not installed

* fix closure errors in compiled bind expr

* fix type annotations

* add amp-video support

* add amp-bind validator proto

* PR comments, more types, TODOs

* fix long line

* remove local assets in bind example

* fix typedef

* more type fixes

* fix lint error

* revert amp-video changes

* rename bind-state to state

* PR comments

* add unit tests

* check shallow equality during mutate, more unit tests

* PR comments

* nit fix

* call attr changed callback for bool attrs

* more PR comments

* lint and style fixes

* PR comments, class-ify bind-expr

* move expr eval to separate file

* fix lint and type errors

* PR comments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants