Skip to content

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

Closed
@buschtoens

Description

@buschtoens

👉 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions