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

Render Element Modifiers #415

Merged
merged 7 commits into from
Jan 22, 2019
Merged

Conversation

pzuraq
Copy link
Contributor

@pzuraq pzuraq commented Dec 14, 2018

Rendered

Huge thanks to @rwjblue, @krisselden, and others on this one!

text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
text/0000-render-element-modifiers.md Outdated Show resolved Hide resolved
## Drawbacks

* Element modifiers are a new concept that haven't been fully stabilized as of
yet. It may be premature to add default modifiers to the framework.
Copy link
Member

Choose a reason for hiding this comment

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

FWIW, I think the only way to make them "main stream" is to motivate their use cases which I think this RFC does very well!

@rwjblue
Copy link
Member

rwjblue commented Dec 14, 2018

@pzuraq - I for one, am very excited to move forward here! These APIs would be extremely helpful for users of sparkles-component (conceptually similar to tagless components) and even certain simple needs of template only glimmer component users...

@bendemboski
Copy link
Collaborator

I like this a lot! The one thing that I have pretty mixed feelings about, though, is did-render re-running when its arguments change. That seems to be overloading its purpose, and I think muddying the mental model. I really like the scroll-to use case not requiring DOM traversal in order to update the scroll position of a list, but I think that's a different problem from the one that the motivation of this RFC outlines.

The primary benefits that I see of this RFC are being able to run setup/teardown code for elements. The ability to interact with elements in between those events without using querySelector and without using did-render to store a reference to the element for later use does not really show up in the motivation section, and IMO is a separate concern. Moreover, there are a whole bunch of ways that I might want to be able to interact with elements between post-setup and pre-teardown, and simply re-running the setup code with different arguments will not always meet my needs. Or it will encourage me to write over-complicated did-render functions to handle two different use cases with the same piece of code. So this feels a little like bike-shedding to me as it doesn't seem to address the core purpose of this RFC, but is just an extra nice-to-have.

I'm also really concerned about breaking symmetry with will-destroy, so I can no longer apply the simple model of putting setup code in did-render and teardown code in will-destroy because did-render might get called repeatedly, while will-destroy will only be called once. In fact, the ember-composability-tools example sorta highlights this. If the library setup code goes here code were not idempotent, and I were to add some changeable argument to the did-render hook, then I could violate the expectations of the library or double-register for events or leak memory or something by calling the setup code twice on the same node without calling the teardown code.

I also think it's counter-intuitive to call did-render when nothing about the element is actually rendering/re-rendering. Reasoning gets more complex -- it's very easy to read and intuitively understand did-render when I know it will only get called once, and in response to the element getting first rendered. Once it can get called multiple times, sometimes because of first render, but sometimes because of some properties in the template context changing, I think it gets a lot harder to reason about and I potentially have to go chasing properties through bindings and other parts of my application just to understand when my did-render code will get called.

@pzuraq
Copy link
Contributor Author

pzuraq commented Dec 14, 2018

@bendemboski that's great feedback! I agree that breaking symmetry feels a little wrong at first, and I definitely agree that we have not motivated it well in this RFC in isolation. I think the motivations can be extracted from the Glimmer components RFC. For instance, one common use case is to setup or teardown a plugin whenever the component is disabled. This appeared quite a bit in our audit.

We felt it was best to allow users to handle these types of cases using modifiers, instead of the other proposed hooks (like didUpdateArgs) since they cover these cases more cleanly. For instance, in a case where a tracked or computed property is invalidated by incoming arguments, there is no need to validate whether the property has changed manually, the change will just trigger the element modifier.

Originally we debated whether or not we could have 3 modifiers, something like did-insert, did-update, and will-destroy. We ended up not going with it because we figured that using did-render without passing any additional args was essentially did-insert, and that splitting did-render into two modifiers would complicate code more often than simplifying it, but we should definitely include it in alternatives. This would actually reflect the three major lifecycle hooks of element modifiers (as defined in managers).

@raido
Copy link

raido commented Dec 14, 2018

I have similar feelings about {{did-render}} breaking the symmetry with {{will-destroy}} as @bendemboski. I understand it is meant to behave like current didRender() hook in Component but I am afraid people will mix it easily with didInsertElement() and then wonder why things happen multiple times.

@pzuraq
Copy link
Contributor Author

pzuraq commented Dec 14, 2018

I am afraid people will mix it easily with didInsertElement() and then wonder why things happen multiple times.

It is key to remember that users will have to opt in to triggering the function multiple times by passing an argument. This will never rerun the action passed in:

<div {{did-render (action this.setupElement)}}></div>

While this will, if @disabled changes:

<div {{did-render (action this.setupElement) @disabled}}></div>

I do think that splitting them out reads well too though:

<div 
  {{did-insert (action this.setupPlugin)}}
  {{did-update (action this.updatePlugin) @disabled}}
  {{will-destroy (action this.teardownPlugin)}}
>
</div>

@raido
Copy link

raido commented Dec 14, 2018

Okay, I some how missed the opt-in part.

But it is like having a method with boolean argument which changes its behavior ever so slightly. While for experienced Ember.js users this will be easily understandable that property mutation will cause a re-render then for new users it still might be confusing to crasp what is going.

I am pretty sure that with passed in second argument anti patterns are a risk, like having boolean state values “if isFirstRender else” to save few lines in template of setting up 2 {{did-render}} modifiers - one with action for the didInsertElement behaviour and other one for update path with additional argument.

@bendemboski
Copy link
Collaborator

bendemboski commented Dec 14, 2018

We felt it was best to allow users to handle these types of cases using modifiers, instead of the other proposed hooks (like didUpdateArgs) since they cover these cases more cleanly.

I don't see this as covering those cases more cleanly. The functionality that it's covering (in the example component you linked) is "dynamically setup/teardown the hammer library anytime the disabled argument changes." But there's any number of reasons I might want to interact with an element in my DOM, and a change to an argument or property of my component is only one reason. For example, a service might trigger an Evented event, or a timer might fire, or I might need to react to (gasp) an observer or external library callback, etc. In those cases I'd need to use a different mechanism for interacting with my DOM that must exist and be usable and robust independent of whether or not did-render recomputes.

I see making did-render recompute as just cherry-picking one particular reason that I need to access the DOM, and while it might be the most common one, it only partially covers a need, and mixes that coverage in with a distinct need (the ability interact with an element at DOM insertion time/save it off for later interaction).

I think it makes much more sense to encourage people to use did-render and will-destroy to capture/clear references to their DOM elements if needed, so they can interact with them later, and then all of those "later" interactions rely on all the same mechanisms people already need to understand for reacting to changes, rather than a fairly opaque and un-intuitive feature in the rendering layer.

I also worry about nested properties:

<div {{did-render this.doSomething itemData.type}}>

will this.doSomething() re-run if itemData is replaced with a different object that has the same type field? Because I doubt I'd want that to happen in this case, but to prevent it from happening, I think the did-render modifier would have to cache its previous arguments, which seems undesirable. But either way, it's another unclear question that adds to learning curve and mental load. Similarly if the argument is an if condition that might re-evaluate to the same value -- does that call the method again or not?

It seems like this is providing functionality that can already be achieved with existing primitives, and in doing so, making those primitives harder to understand and use.

@pzuraq
Copy link
Contributor Author

pzuraq commented Dec 14, 2018

But there's any number of reasons I might want to interact with an element in my DOM, and a change to an argument or property of my component is only one reason. For example, a service might trigger an Evented event, or a timer might fire, or I might need to react to (gasp) an observer or external library callback, etc

In the general case of reacting to a callback of some kind, or an async event, it would make sense to capture a reference to the element, definitely. However, in the more common and specific case of wanting to run the change when a specific arg/tracked-property/computed-property changes, I disagree. In fact, currently there are cases where observers are the only way for us to reasonably react to these changes:

export default Component.extend({
  // arguments
  model: null,

  didInsertElement() {
    if (this.model.isEnabled) {
      this._setupPlugin();
    }
  },

  willDestroyElement() {
    if (this.model.isEnabled) {
      this._teardownPlugin();
    }
  },

  updatePlugin: observer('model.isEnabled', function() {
    if (this.model.isEnabled) {
      this._setupPlugin();
    } else {
      this._teardownPlugin();
    }
  })
});

We have no guarantee that didUpdateAttrs (or didUpdateArgs) or didUpdate will trigger because there is no guarantee they are used in templates, or set directly in arguments, so we need to observe the property directly for changes. By directly stating that the property is an explicit dependency of the modifier, we avoid the need to use observation or callbacks in many cases.

I also worry about nested properties

This is a fair point too, but either way the validation logic will have to exist somewhere. The same issue would exist using hooks:

export default Component.extend({
  didUpdateAttrs() {
    if (this.itemData.type !== this._previousType) {
      this._previousType = this.itemData.type;

      this._updatePlugin();
    }
  }
})

And this actually compounds the problem, since the hook is disconnected from the action we are trying to take. You can see in many examples that these hooks tend to become a catch all for many different, semi-related or unrelated actions that all need to occur at once.

Edit: Put another way, the current solutions through hooks often rely on timing and implicit state (that values are being consumed by templates). There are workarounds to these pain points, but allowing some modifiers to be passed argument values eliminates them entirely.

@bendemboski
Copy link
Collaborator

bendemboski commented Dec 14, 2018

In fact, currently there are cases where observers are the only way for us to reasonably react to these changes

Agreed! So if I have component code that needs to react to such changes, but doesn't happen to require direct DOM access, then I'm using observers anyway (or implementing a different notification mechanism like Evented or something). So unless we want to encourage people to put invisible elements in the DOM just to use their did-render modifiers to substitute for observers, we haven't solved the problem of "running code in my component in response to argument/state changes without using observers," we've only solved the problem of "running code in my component that happens to need direct DOM access, and access to one and only one DOM element/sub-tree, in response to argument/state changes without using observers." And in the process made it harder to understand and reason about the clearly-needed and simple use case that did-render does address, and nothing else addresses ("ability to access DOM elements rendered by my component without using document.querySelector").

This is a fair point too, but either way the validation logic will have to exist somewhere. The same issue would exist using hooks

Right, but that logic would be clear, readable, understandable, and not happening in hidden magical code that could end up doing something I don't want it to do. It also would be able to cover all use cases, rather than requiring a totally different implementation if I need to access one DOM element vs. two DOM elements with no common ancestor element vs. zero DOM elements.

You can see in many examples that these hooks tend to become a catch all for many different, semi-related or unrelated actions that all need to occur at once.

did-render won't completely solve this -- a lot of my code that will end up living in didUpdateArgs is code that recomputes internal state where for whatever reason a computed property isn't possible/desirable (probably a greater quantity than code that directly accesses the DOM). But the whole problem is easily solved with .on() or with decorators:

@did-update-args
doThing1() {
  // ...
}

@did-update-args
doThing2() {
  // ...
}

I understand the desire to be able to run code that interacts with DOM elements when some argument/state updates, but I don't think I've heard a convincing reason for why that has to be overloading did-render -- it's still sounding to me like the main argument is "because it's there."

My alternate proposals would be:

  1. Do nothing and let people implement this using observers/saving references to elements so we can get more information on if/how it's needed before committing to an API. Advise people to save references to elements they need to interact with later, or to save references to the component's top-level element(s) and use querySelector to access their descendant elements. This could be done in a forward-looking manner by only allowing one argument to the did-render modifier so people are required to use the action helper to bind arguments. Then if we decide to add the rerun functionality, we could be sure it wouldn't break anything because we would be adding support for passing arguments directly into the did-render modifier (as opposed to through the action helper) at the same time.

  2. Implement a separate modifier like you mentioned above:

<div
  {{did-render this.setSomeStuffUp}}
  {{did-update this.changeSomeStuff @arg1 @arg2}}
/>
  1. (I like this less than the other options, but more than the current proposal) Make the dependency modifier explicit, and not forced to be identical to the arguments to the arguments to the function/action:
<div {{did-render this.doSomeStuff 'foo' @arg1 rerun-on=(array @arg1 @arg2)}}/>

or maybe

<div {{did-render (action this.doSomeStuff 'foo' @arg1) (rerun-on @arg1 @arg2)}}/>

@pzuraq
Copy link
Contributor Author

pzuraq commented Dec 14, 2018

So if I have component code that needs to react to such changes, but doesn't happen to require direct DOM access, then I'm using observers anyway (or implementing a different notification mechanism like Evented or something).

This is true, but the point is we're chipping away at the need for observers or external hooks by making dependencies more explicit in general. Like I said, I do think there will always be cases where we need to do something asynchronously and imperatively in JS apps, but the majority of cases do actually come down to field access.

I understand the desire to be able to run code that interacts with DOM elements when some argument/state updates, but I don't think I've heard a convincing reason for why that has to be overloading did-render -- it's still sounding to me like the main argument is "because it's there."

The main point is to avoid having to use render hooks such as didInsertElement, willDestroyElement, didRender, and didUpdate within components. These hooks do not exist on Glimmer components in the current proposal, and as I mentioned before they essentially rely on implicit state and timing for things to "just work" in most cases.

Option 2 also satisfies this constraint, since it would give users a way to trigger code to update their element plugins/code when arguments/tracked properties/computeds change. I personally have no objection to having three modifiers instead of two, and I do think it'll be clearer for some use cases.

@bendemboski
Copy link
Collaborator

Okay, I think I have fully expressed (to say the least!) my concerns here, and I feel heard, so thanks for taking the time to have this discussion 😺

@pzuraq
Copy link
Contributor Author

pzuraq commented Dec 14, 2018

@bendemboski and I discussed this a bit more on Discord and agreed that there are definitely use cases for a {{did-insert}} which never reruns, even if passed args change. I'll update the RFC accordingly when I get a moment, commenting now to capture the outcome of the conversation.

@rwjblue rwjblue added T-framework RFCs that impact the ember.js library Octane labels Dec 15, 2018
@tomdale
Copy link
Member

tomdale commented Dec 20, 2018

@bendemboski Your arguments sparked a lot of good conversation on the core team. Thank you for taking the time to write them up!

@NullVoxPopuli
Copy link
Sponsor Contributor

Will the destroy modifier be awaited?
Like, if I need to interact with some wasm API or indexeddb or web worker (idk).

My main concern is the possibility of needing to do something async on destroy where the component is destroyed before the cleanup function finishes.

@bendemboski
Copy link
Collaborator

I'm not sure if it belongs in this RFC or if it's more of an implementation detail, but does the did-update modifier need some kind of equivalent to the double-render assertion in case the handler modifies one of the did-update arguments?

@pzuraq
Copy link
Contributor Author

pzuraq commented Jan 9, 2019

@NullVoxPopuli currently destruction hooks in components do not block on async, and I don't think it would be a good idea to do that here. You should be able to clean up asynchronously without blocking rendering in most cases, which I think is probably a better idea performance-wise.

@bendemboski that's a great point, I do think that's more implementation specific but we should definitely consider it when we implement. My gut says yes? In fact it could already be handled by the double render assertion as is

@rwjblue
Copy link
Member

rwjblue commented Jan 11, 2019

We reviewed this RFC at the core team meeting today, and believe that this is ready for final comment period.

Co-Authored-By: pzuraq <me@pzuraq.com>
@rwjblue
Copy link
Member

rwjblue commented Jan 22, 2019

This has been in final comment period for around 10 days. There have been no new issues/concerns raised during FCP so this is ready to launch 🚀 .

@rwjblue rwjblue merged commit 2fd67aa into emberjs:master Jan 22, 2019
rwjblue added a commit to rwjblue/ember-render-modifiers that referenced this pull request Jan 28, 2019
This is the initial implementation of
[emberjs/rfcs#415](https://emberjs.github.io/rfcs/0415-render-element-modifiers.html).

It currently depends on
[ember-modifier-manager-polyfill](https://github.com/rwjblue/ember-modifier-manager-polyfill)
in order to support Ember versions prior to 3.8 (should work back to at
least 2.12).
rwjblue added a commit to emberjs/ember-render-modifiers that referenced this pull request Jan 28, 2019
@rwjblue
Copy link
Member

rwjblue commented Jan 29, 2019

FYI - I just published @ember/render-modifiers 1.0.0 with support back to Ember 2.12 (by way of ember-modifier-manager-polyfill). There is still a bit of work to do (need tons more documentation), but its a good start...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Final Comment Period Octane T-framework RFCs that impact the ember.js library
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants