-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Conversation
request.newHeight = newHeight; | ||
request.newWidth = newWidth; | ||
request.newHeight = newHeight || request.newHeight; | ||
request.newWidth = newWidth || request.newWidth; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assert?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
map helper.
There was a problem hiding this comment.
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++) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) === ']') { |
There was a problem hiding this comment.
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]
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 expectattr
to be a toggled-off boolean attribute[attr]="'false'"
will expectattr
to have the string value"false"
if (property === 'text') { | ||
element.textContent = newValue; | ||
} else if (property === 'class') { | ||
element.classList = newValue; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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']; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(':'); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
ee5d727
to
e655b07
Compare
/to @cramforce @dvoytenko /cc @aghassemi |
bfa8f69
to
a4c8d61
Compare
There was a problem hiding this 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', |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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"> |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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 ' + |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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} */ |
There was a problem hiding this comment.
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_); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Let's !!
this.
There was a problem hiding this comment.
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_ = []; |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
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_); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
;
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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())
There was a problem hiding this comment.
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 === '') |
There was a problem hiding this comment.
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"
.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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'], |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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_, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
@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(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Object.keys()
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?"
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
LGTM from me. Deferring to @cramforce and @jridgewell for the final approval. |
There was a problem hiding this 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 = {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename to cache
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
* 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
* 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
Partial for #6199.
<amp-bind-state>
componentsrc
and a few other attributes for builtins (amp-img, amp-video)AMP.setBindState(key=value)
action for now (can be changed later)TODO: