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

Pre-RFC: render helpers (analogous to render modifiers) #484

Closed
buschtoens opened this issue Apr 25, 2019 · 13 comments
Closed

Pre-RFC: render helpers (analogous to render modifiers) #484

buschtoens opened this issue Apr 25, 2019 · 13 comments

Comments

@buschtoens
Copy link
Contributor

buschtoens commented Apr 25, 2019

👉 I implemented this as ember-render-helpers

RFC #415 Render Element Modifiers introduced the following render modifiers which, are available via the official @ember/render-modifiers package:

These helpers allowed to reduce the API surface area of the new @glimmer/component. The commonly used classic Ember Component hooks (didInsertElement, didReceiveAttrs, willDestroyElement) are replaced with the respective render modifiers.

In my opinion, this works extremely well for {{did-insert}} (didInsertElement) and {{will-destroy}} (willDestroyElement()), which are used to setup and teardown element related state.

There also are very valid applications for {{did-update}}, like this example from the RFC:

<div
  {{did-insert this.setScrollPosition @scrollPosition}}
  {{did-update this.setScrollPosition @scrollPosition}}
  class="scroll-container"
>
  {{yield}}
</div>
export default class Component {
  @action
  setScrollPosition(element, [scrollPosition]) {
    element.scrollTop = scrollPosition;
  }
}

Another big advantage of {{did-update}} over a generic didReceiveAttrs() / didUpdate() hook is that you explicitly list the arguments you want to observe, whereas the generic hook would be re-evaluated whenever any argument changes. With {{did-update}} you can also observe any other property on the component, whereas the hook only gets called when arguments to the component change.

However, besides all the things {{did-update}} has going for it, I believe that it will often only be used as a workaround for the missing didReceiveAttrs() / didUpdate() hook and that users will not actually use the element that is passed to the fn then. For these scenarios, {{did-update}} as an element modifier is just a hack. It also forces you to render elements to the DOM, which is not an option for "renderless" components that only {{yield}} state.

To better support these scenarios, I think we should provide complimentary template helpers, that behave exactly the same way, except for that they don't pass an element to fn.

For {{did-insert}} and {{did-update}} this should be easily achieved with the public classic Ember Helper API. For {{will-destroy}} I don't think that it'll be possible with the public API.

{{did-insert}}

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';

export default class DidInsertHelper extends Helper {
  didRun = false;

  compute(positional: any[], named: Record<string, any>): void {
    const fn = positional[0] as (positional: any[], named: typeof named) => void;
    assert(
      `\`{{did-insert fn}}\` expects a function as the first parameter. You provided: ${fn}`,
      typeof fn === 'function'
    );
    if (this.didRun) return;
    this.didRun = true;
    fn(positional.slice(1), named);
  }
}

{{did-update}}

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';

export default class DidUpdateHelper extends Helper {
  didRun = false;

  compute(positional: any[], named: Record<string, any>): void {
    const fn = positional[0] as (positional: any[], named: typeof named) => void;
    assert(
      `\`{{did-insert fn}}\` expects a function as the first parameter. You provided: ${fn}`,
      typeof fn === 'function'
    );
    if (!this.didRun) {
      this.didRun = true;
      return;
    }
    fn(positional.slice(1), named);
  }
}

{{will-destroy}}

Assuming that willDestroy is called for instances of Helper, which I am not sure about.

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';

export default class DidUpdateHelper extends Helper {
  fn?: (positional: any[], named: typeof named) => void;
  positional?: any[];
  named?: Record<string, any>;

  compute(positional: any[], named: Record<string, any>): void {
    const fn = positional[0] as ;
    assert(
      `\`{{did-insert fn}}\` expects a function as the first parameter. You provided: ${fn}`,
      typeof fn === 'function'
    );
    this.fn = fn;
    this.positional = positional;
    this.named = named;
  }

  willDestroy() {
    if (this.fn && this.positional && this.named)
      this.fn.call(null, this.positional, this.named);
    super.willDestroy();
  }
}

I personally don't care much for {{did-insert}} and {{will-destroy}}, but I think {{did-update}} is crucial.

For instance, I cannot update my ember-link addon to the Octane programming model, if I want to keep asserting the arguments that were provided to the <Link> component. It was based on sparkles-component, which still had an didUpdate hook, which I used like:

  didUpdate() {
    super.didUpdate();

    assert(
      `You provided '@queryParams', but the argument you mean is just '@query'.`,
      !('queryParams' in this.args)
    );
    // more assertions here...
  }

I can't use the {{did-update}} element modifier, since the template contains no DOM tags and only {{yield}}s some state.

@richard-viney
Copy link
Contributor

I noticed a similar thing emerging with {{did-update}} when we updated our codebase to use render modifiers instead of lifecycle hooks. Every now and then you'd end up with a {{did-update}} not directly related to the HTML element it was on, e.g. when you want to run code in response to changes to a specific argument. These generally ended up getting added to the root element of the component's template.

I do worry a little that such patterns encourage users back towards using observer-style patterns. although at least this time they're not synchronous!

The did-update helper does seem like the most relevant one. did-insert and will-destroy are directly linked to element lifecycle, and it may be best to keep it that way, whereas with did-update nothing has actually happened to the DOM element itself in order for it to fire its fn, so in some ways having it as an element modifier at all is an interesting choice.

For a component that cares about lifecycle but has no template I expect the recommendation is to use constructor() and willDestroy().

@buschtoens
Copy link
Contributor Author

For instance, I cannot update my ember-link addon to the Octane programming model, if I want to keep asserting the arguments that were provided to the <Link> component. It was based on sparkles-component, which still had an didUpdate hook, which I used like:

  didUpdate() {
    super.didUpdate();

    assert(
      `You provided '@queryParams', but the argument you mean is just '@query'.`,
      !('queryParams' in this.args)
    );
    // more assertions here...
  }

I can't use the {{did-update}} element modifier, since the template contains no DOM tags and only {{yield}}s some state.

In the meantime I did manage to update ember-link. I went with creating an extra validation template helper:

https://github.com/buschtoens/ember-link/blob/54b510cf27a1ccc9a943e3564541cd067c46cd2a/addon/components/link/template.hbs#L1

https://github.com/buschtoens/ember-link/blob/54b510cf27a1ccc9a943e3564541cd067c46cd2a/addon/helpers/ember-link/validate-args.ts

Unfortunately, this pollutes the global namespace. However, once template imports come along, this won't be an issue any more. To me it still feels a bit weird to off-load all this to the template, but I am trying align my brain with the "the template is the driver of the program / state" mentality.

Even when template imports land, I still think that {{did-update}} is a worthwhile helper to have. I am totally open to have my mind changed though.

@buschtoens
Copy link
Contributor Author

Tangentially related: I made ember-on-helper, which is the helper equivalent of the {{on}} modifier. It allows you to pass an arbitrary EventTarget:

{{on this.someEventTarget "click" this.someListener}}

I needed it in order to bind a global keydown listener on window.

@willviles
Copy link

In my opinion, a {{did-update}} helper makes much more sense than a modifier in most use cases. In the following example, I've implemented the {{did-update}} helper proposed by @buschtoens to solve the clear problem with listening to args mutations when there's no top-level DOM element.

// components/overlay.js

import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { action } from '@ember/object'

export default class OverlayComponent extends Component {
  // @visible?: boolean
  // @destinationElement?: HTMLElement

  @tracked visible = this.args.visible

  @action
  didUpdateVisible () {
    this.visible = this.args.visible
  }

  @action
  close () {
    this.visible = false
  }
}
{{! -- templates/components/overlay.js --}}

{{did-update this.didUpdateVisible @visible}}

{{#if this.visible}}
  <EmberWormhole @destinationElement={{@destinationElement}}>
    <div class="overlay-container">
      <div class="overlay" ...attributes>
        {{yield (hash
          close=this.close
          visible=this.visible
        )}}
      </div>
    </div>
  </EmberWormhole>
{{/if}}

In the current programming model, what's the alternative? I tried using the following in the constructor, but that didn't track mutations:

addObserver(this.args, 'visible', this.didUpdateVisible.bind(this))

@buschtoens
Copy link
Contributor Author

I just published ember-render-helpers. 🌟😊

@gossi
Copy link

gossi commented Aug 28, 2019

This would be a very welcome addition for renderless / provider components. E.g. you had to use an arbitrary, invisible element to which you bound a modifier that updates back to your component. So, elements are not needed anymore.

before:

<meta {{did-update this.updateFoo @foo}}>

after:

{{did-update this.updateFoo @foo}}

I still think {{did-insert}} still makes sense as modifier as it establishes the connection between html element and component. However, once you have the element reference in your component, you can use the willDestroy() hook from the component. If you do use handlbars properly I do not even see a need for {{will-destroy}} helper or modifier, unless you kill the element with javascript before the component is destroyed anyway (which you also should not do).
I also question the {{did-insert}} helper, that's stuff that can be done in the constructor of your component, unless there are timing constraints I cannot evaluate properly at the moment.

@buschtoens
Copy link
Contributor Author

#484 (comment):

I personally don't care much for {{did-insert}} and {{will-destroy}}, but I think {{did-update}} is crucial.

There are only very few cases where {{did-update}} as a modifier makes sense. And it's the other way around with {{did-insert}} and {{will-destroy}} as helpers.

However, if you imagine a world where templates are not necessarily backed by a component class instance, they might become more useful.

Either way, now we have full feature parity in both directions. Just because something is there, doesn't mean that you have to use it. And luckily, with embroider on the horizon, unused helpers and modifiers will be stripped from the build. 🙌

@pzuraq
Copy link
Contributor

pzuraq commented Aug 28, 2019

@willviles I think that pattern should be discouraged in general. That was actually a major reason why the lifecycle hooks were removed, and a major discussion point on the Glimmer Components RFC. Fundamentally, that pattern is better rewritten as a getter based on derived state, rather than manually, imperatively updating state based on observed changes.

I think in that example, this is pretty clear. If you have a more nuanced/difficult example that you're struggling with, I'd love to dig in, we're building out guides for this type of thing right now. Totally understand that in many cases, it's going to be difficult or counterintuitive to figure out the path forward, so we want to help 😄 I actually misread the example, it is a good one! I'll add a followup that digs in.

It's also probably important to note, in general we did not expect the render modifiers to be the recommended path forward in the future. They were seen as a way to unblock users who are converting to Octane and who have lifecycle-hook heavy apps, an escape-hatch essentially. In the long run, we wanted to push users toward either deriving state using autotracking, or writing modifiers specifically designed for solving targeted use cases. I'd say the same thing about these helpers if we make them (and I definitely think we should! They're also a valuable escape-hatch).

@buschtoens

There are only very few cases where {{did-update}} as a modifier makes sense. And it's the other way around with {{did-insert}} and {{will-destroy}} as helpers.

I'm wondering if this is an indicator that the design of the escape hatches is off. {{did-update}} always did feel a bit awkward, but the idea was that it was one of the base modifier lifecycle hooks so it would be a 1-1 mapping, and would let people write logic that could later be extracted to a custom modifier.

There was an initial proposal for a {{did-render}} modifier, that would run on insertion and also update. I'm wondering if this might be more ergonomic, and help users "think in modifiers" like we're aiming for.

@pzuraq
Copy link
Contributor

pzuraq commented Aug 29, 2019

Followup to @willviles example (which I misread in my previous comment, edited above). Let's say we have the following as our component today:

// components/overlay.js

import Component from '@ember/component';

export default Component.extend({
  visible: false,
  destinationElement: null,

  actions: {
    close () {
      this.set('visible', false)
    }
  }
}
{{! -- templates/components/overlay.js --}}

{{#if this.visible}}
  <EmberWormhole @destinationElement={{@destinationElement}}>
    <div class="overlay-container">
      <div class="overlay" ...attributes>
        {{yield (hash
          close=(action 'close')
          visible=this.visible
        )}}
      </div>
    </div>
  </EmberWormhole>
{{/if}}
{{!-- invocation --}}

<Overlay 
  @visible={{this.overlayVisible}} 
  @destinationElement={{this.overlayDestination}}

  as |overlay|
>
  Hello, world!

  <button {{action overlay.close}}>
    Close
  </button>
</Overlay>

This should generally work pretty well. When we initially toggle this.overlayVisible in our surrounding context to true, the overlay will begin showing. The close action that we pass through will also allow the overlay to toggle itself off. Awesome 😄

Note: This would also activate Ember's old 2-way-binding system, causing the parent's value, isVisible, to toggle back to false as well. We'll revisit this in a bit.

When we first try to convert this to Glimmer Components, we run into an issue with the visible argument though. It's no longer mutable:

// components/overlay.js
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class Overlay extends Component {
  @action
  close () {
    this.args.visible = false; // this will throw an error
  }
}

So, what do we do here? The issue is that we're using a single class field for state that is both internal to this component, and external to it, in the surrounding context. So, one option we could try is to make a getter that combines internal component state and external argument state:

// components/overlay.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class Overlay extends Component {
  @tracked _visible = true;

  get visible() {
    return this.args.visible && this._visible;
  }

  @action
  close () {
    this.visible = false;
  }
}

This will work at first! However, when we toggle the overlay off, we'll find that it's never possible to turn it back on. It's internal state is now frozen. This is what led to the didUpdateVisible solution from the previous example:

// components/overlay.js

import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { action } from '@ember/object'

export default class OverlayComponent extends Component {
  // @visible?: boolean
  // @destinationElement?: HTMLElement

  @tracked visible = this.args.visible

  @action
  didUpdateVisible () {
    this.visible = this.args.visible
  }

  @action
  close () {
    this.visible = false
  }
}
{{! -- templates/components/overlay.js --}}

{{did-update this.didUpdateVisible @visible}}

{{#if this.visible}}
  <EmberWormhole @destinationElement={{@destinationElement}}>
    <div class="overlay-container">
      <div class="overlay" ...attributes>
        {{yield (hash
          close=this.close
          visible=this.visible
        )}}
      </div>
    </div>
  </EmberWormhole>
{{/if}}

This works, but it definitely feels a bit strange. We have an action that is called from the template, that runs when the parent component changes a value, and then updates the state of our component. This is a codesmell in general, and the issue it's pointing to is that we have a mismatch in state management. This component is trying to treat the same piece of state, visibility, as if it was both local state and external state.

This is going to cause headaches in the long run, since the developer must manually keep local state in sync with external state whenever it changes. For example, since Glimmer Components are not 2-way data bound, there's no way for parents to know about child state changes, so for instance if this was how we activated the overlay in the parent:

if (!this.isVisible) {
  this.isVisible = true;

  // do some other setup
}

Then we would be back in the same boat as the previous example, where the overlay would never show again after it was closed the first time.

So, how do we fix this, in a way that is not error prone? The answer is to always mutate the state where it originates, in this case in the parent component. We can update the Overlay component to receive a @close argument which toggles the isVisible value in the parent context, instead of in this component:

{{! -- templates/components/overlay.js --}}

{{#if @visible}}
  <EmberWormhole @destinationElement={{@destinationElement}}>
    <div class="overlay-container">
      <div class="overlay" ...attributes>
        {{yield (hash
          close=@close
          visible=@visible
        )}}
      </div>
    </div>
  </EmberWormhole>
{{/if}}

The end result is actually a Template-Only component, since we're just passing arguments through.

There are definitely times when this type of refactor will be a bit painful, and it can be tricky to figure out if something is local state, or if it belongs to the parent. I'm hoping we can get more examples so it'll be easier for users to figure out how to update their components, we're planning on building up a library of them in the Ember Atlas (I'll be adopting this one shortly!)

@mydea
Copy link
Contributor

mydea commented Oct 28, 2019

While writing a brand new app in all-Octane has been very pleasant so far, this specific issue mentioned here has continued to be rather weird to me. I very much second the sentiment that {{did-insert}} and {{will-destroy}} make a lot of sense and feel great to use as modifiers, but {{did-update}} doesn't really make a lot of sense to me in most cases. I end up just adding it to a random DOM element, which is not even possible in some scenarios.

As an example, we have a component that loads some data from an API. We use a task to load the data, and there are some arguments passed into the components that are used for the data loading, so if they change we want to trigger a reload. It would be hard to rewrite this with a getter, especially if you're at the same time trying to avoid juggling with promises everywhere.

Here is a simplified example:

export default class MyComponent extends Component {
  @tracked items = [];

  constructor() {
    super(...arguments);
    this.loadData.perform();
  }

  @restartableTask
  *loadData() {
      // Imagine this depends on some args...
      let items = yield fetch(...);
      this.items = items;
  }
}
{{#if this.loadData.isRunning}}
  Loading...
{{else if this.items.length}}
  Show items...
{{else}}
  No items
{{/if}}

Without adding an unnecessary wrapper div, this is not really possible at all. With helpers, it is possible, but honestly, it feels weird to put this into the template at all, as this data loading is not really a template concern. I do appreciate the manual specifying of the values that should be watched, though. Still, a helper makes 100% more sense to me for {{did-update}} than a modifier.

@pzuraq
Copy link
Contributor

pzuraq commented Oct 28, 2019

@mydea FWIW, the idea when we designed modifiers and removed lifecycle hooks was that in the long run, we should ideally develop helpers that allow us to consume data like tasks without manually restarting them. So for example, ideally your sample code would update the promise automatically if args changed, no need to call loadData.perform() at all.

Some exploration in this space has been done with libraries like async-await-helpers, and I think that a consumption type API would make more sense for most of these use cases where args are triggering promises.

Essentially, the goal is to take some imperative, side-effecting type code (like making an async request for data), and wrap it for usage in a declarative way, where the state is derived directly from incoming arguments and not necessarily manually called or started. ember-concurrency almost does this, and it's a great example of the benefits, but I think we can iterate to something just a bit better.

render-modifiers were seen as a temporary stepping stone during the design discussion. I think render-helpers are also useful in this period, as we build out/explore new models as a community, but I think long term we should be trying to build a better abstraction here.

@sandstrom
Copy link
Contributor

Does this addon/experiment by @pzuraq solve this issue? If so, maybe we can close this issue?
https://github.com/pzuraq/ember-could-get-used-to-this

See https://www.pzuraq.com/introducing-use/

@buschtoens
Copy link
Contributor Author

Yes, definitely! ✅

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

No branches or pull requests

7 participants