emberjs / rfcs

RFCs for changes to Ember
https://rfcs.emberjs.com/
794 stars 408 forks source link

Component helper in JS #434

Closed mehulkar closed 2 years ago

mehulkar commented 5 years ago

It's possible to use the {{component}} helper in templates to specify a dynamic component name. This allows all kinds of polymorphic approaches to rendering data, which is awesome. However, as of now, it is not easily possible to bind a different set of arguments to dynamic components, which means that your components need to have the exact same API.

It's debatable whether or not this is a good idea, but if there was an equivalent to the component helper in JS, we would be able to construct a component with a different set of arguments.

The goal is to be able to do something like this:

{{#each this.items as |item|}}
  {{component item.component}}
{{/each}}
// app/controllers/index.js
export default Controller.extend({
  items: computed('model', () => {
    return this.model.map(item => {
      let component;
      if (item.type === 'foo') {
        component = createComponent('special-component', { arg1: item.name, arg2: item.type });
      } else {
        component = createComponent('default-component', { otherArg: item.name });
      }
      return Object.assign(item, { component });
    });
  });
});

This lets us use separate components based on item.type, but allows for flexibility in their API.

sdhull commented 5 years ago

I have also wanted the ability to do fooComponent = component('foo-display', {foo, bar}); in .js files. Usually I've figured out tricksy ways to work around the fact that this is impossible (typically by using the {{component}} helper in a parent .hbs context and passing it down) but I'm sure there are places where it would be clearer to simply instantiate the component renderer where it's needed.

I'd like to add that it would be awesome if instances of CurriedComponentDefinition were callable like a normal function (so that it might be renderable by 3rd party libraries). This is probably impossible but a guy can dream.

sdhull commented 5 years ago

Maybe

import { componentRenderer } from '@ember/component';

I'm not really sure where it would be appropriate to export such a thing. I also think it needs some sort of rendering context to work...?

chancancode commented 5 years ago

As I mentioned in the other thread, the difficulty here is passing bound arguments. Something like component(“foo-display”, { foo, bar }) is only going to work if you expect foo and bar to be const/unbound, which isn’t always going to be appropriate.

Herriau commented 5 years ago

@chancancode I wouldn't expect foo or bar to be bound. Talking from experience of the various use cases where we have leveraged our homemade createCurriedComponentDefinition() (see emberjs/ember.js#17509), we have rarely felt the need for any of the curried properties to be bound.

Most of the time these curried component definitions are created within computed property bodies or custom template helpers, so a change in any of the upstream values causes a new curried component definition to be emitted. This hopefully is handled efficiently by glimmer (e.g. if a new definition object is emitted but it has the same shape as the previous definition object then the old component shouldn't be completely torn down).

mehulkar commented 5 years ago

I wouldn't expect foo or bar to be bound.

ehh.. i'd expect it to work the same way as the component helper in the template, and since that's bound, I think this would be too. That would be a pretty huge ergonomics issue if they didn't behave the same.

chancancode commented 5 years ago

Most of the time these curried component definitions are created within computed property bodies or custom template helpers, so a change in any of the upstream values causes a new curried component definition to be emitted.

I think you are describing bound arguments 😉

This hopefully is handled efficiently by glimmer (e.g. if a new definition object is emitted but it has the same shape as the previous definition object then the old component shouldn't be completely torn down).

Nope.

ehh.. i'd expect it to work the same way as the component helper in the template, and since that's bound, I think this would be too. That would be a pretty huge ergonomics issue if they didn't behave the same.

I agree, but it would need some kind of different api/syntax, otherwise there is no way to track the property/changes.

Herriau commented 5 years ago

ehh.. i'd expect it to work the same way as the component helper in the template, and since that's bound, I think this would be too. That would be a pretty huge ergonomics issue if they didn't behave the same.

@mehulkar That just doesn't seem possible with the syntax you are proposing. But again I really don't see this as limiting at all. I would simply rewrite your example as:

// app/controllers/index.js
export default Controller.extend({
  mappedModels: computed('model.@each.{name,type}', function() {
    return this.model.map(item => {
      let component;

      switch (item.type) {
        case 'foo':
          component = createComponent('special-component', { arg1: item.name, arg2: item.type });
          break;

        default:
          component = createComponent('default-component', { otherArg: item.name });
      }

      return Object.assign({}, item, { component });
    });
  });
});

Nope.

@chancancode That's unfortunate. How is this handled today with the (component ...) helper? Wouldn't a change in upstream curried value cause a new curried definition object to be emitted there as well?

chancancode commented 5 years ago

@Herriau so long as the first argument to the helper doesn't change, the definition object is stable https://github.com/glimmerjs/glimmer-vm/blob/master/packages/@glimmer/runtime/lib/references/curry-component.ts#L40-L42

This works because the args are curried as references https://github.com/glimmerjs/glimmer-vm/blob/master/guides/04-references.md

Herriau commented 5 years ago

@chancancode Thank you for the exhaustive list of links. In that case, and if glimmer is to remain as such, it does indeed look like we would want a way for developers to pass values that are bound to some context.

In the few cases where we've needed this, we have resorted to alias() and reads() (which was possible in our case since our homemade createComponent() is based on EmberObject.extend()):

const component = createComponent('special-component', {
    item,
    arg1: reads('item.name'),
    arg2: reads('item.type'),
});

This isn't to say that computed properties should be supported at all, but syntactically-speaking I feel like this makes sense.

knownasilya commented 5 years ago

@Herriau I'd recommend creating it as an addon that people can use and find caveats and API limitations through that and then feeding it back into the RFC.

mehulkar commented 4 years ago

@chancancode @pzuraq does @tracked change the landscape here? If passed arguments are tracked, I'd imagine that a JS component helper would be able to update?

ef4 commented 3 years ago

I do think tracked properties make it possible to revisit this.

As we move toward components as really first class values (meaning you can import a component into JS, pass it around, and eventually invoke it), I think the gap of not being able to curry in JS becomes more glaring.

boris-petrov commented 3 years ago

I believe this here is very much the same as #563, am I right?

pzuraq commented 3 years ago

@boris-petrov Not quite, this is discussing allowing you to curry component definitions, e.g. provide arguments to them. The end result is still a component definition, not a rendered component.

NullVoxPopuli commented 3 years ago

Now that components can be passed around as values and then rendered via <this.myComponent>, what would it take to curry args in js? The component helper can't be 'that' magical, yeah?

ef4 commented 3 years ago

I banged on this for a while, it did not turn out to be easy. I think it’s equivalent to being able to splat component args and block args, because with those features you can implement currying as deriving a wrapper component.

The other problem with currying in JS is reactivity. You can’t make a tracked local variable, so it’s easy to accidentally curry values that don’t update.

On Fri, Jul 9, 2021 at 6:57 AM NullVoxPopuli @.***> wrote:

Now that components can be passed around as values and then rendered via

, what would it take to curry args in js? The component helper can't be 'that' magical, yeah? — You are receiving this because you commented. Reply to this email directly, view it on GitHub , or unsubscribe .
NullVoxPopuli commented 3 years ago

:thinking: the same problems need to be solved for currying args to modifiers, then ya?

I wanted to do this earlier today:

<div {{@something.modifier}} />

where @something's modifier was preconfigured with a couple args

pzuraq commented 3 years ago

The other problem with currying in JS is reactivity. You can’t make a tracked local variable, so it’s easy to accidentally curry values that don’t update.

Right, this is similar to the problems we dealt with for invoking helpers in JS. I think the solution would be similar:

let curried = curry(MyComponent, () => {
  return {
    named: {
      someArg: this.args.someArg,
    }
  };
});

Then the process of getting the arguments can necessarily be tracked, allowing us to react whenever they change.

🤔 the same problems need to be solved for currying args to modifiers, then ya?

Yes, in fact the currying infrastructure has recently been unified in the VM so all curried modifiers, helpers, and components end up being the same thing internally. I think we could introduce a single keyword in both JS and templates that can be used with all of them in order to simplify things.

ef4 commented 3 years ago

That solves a different problem than I was thinking of. It makes it possible to detect which tracked state is consumed by the arguments.

But it doesn't prevent people from currying locals:

let someArg = 1;

let curried = curry(MyComponent, () => {
  return {
    named: {
      someArg
    }
  };
});

function doesNotReact() {
  someArg = 2;
}

Maybe that's fine, I mean you do need to learn to put @tracked on things and in a case like this you'd realize you have nowhere to put it unless you put the arg onto an object.

NullVoxPopuli commented 3 years ago

Is it also fine that this curry function would allow people to pass positional args to components?

would we want wrapper utilities to help out with that:

function component(Klass, thunk) {
  return curry(Klass, () => {
    let named = thunk();
    return { named };
  });
}

let curried = component(MyComponent, () => ({ fruit: this.pepper }))
pzuraq commented 3 years ago

@ef4 yeah I think that's a more general problem, you can also use locals in getters too for instance. I think we just need to double down on describing how tracked state works in general here.

@NullVoxPopuli yes, we would definitely want to be able to pass positionals, even components can receive positionals. I'm not sure about making any short hands/utilities for the initial version of it, it'd probably be best for it to cover just the basics and then we can figure out a better shorthand in the future, but I'd definitely be open to thinking about it.

wagenet commented 2 years ago

I'm closing this due to inactivity. This doesn't mean that the idea presented here is invalid, but that, unfortunately, nobody has taken the effort to spearhead it and bring it to completion. Please feel free to advocate for it if you believe that this is still worth pursuing. Thanks!

NullVoxPopuli commented 2 years ago

This hadn't really found a resolution, here is my attempt, given we can "use anything as values" since ember-source 3.25.

The intent in the original post is to dynamically use some components based on some value in JS.

import DefaultComponent from './default';
import FooComponent from './foo';

export default class Demo extends Component {
  get items() {
    return this.args.someArray.map(item => {
      let component = DefaultComponent;

      if (item.type === 'foo') {
        component = FooComponent; 
      } 

      return { ...item, component };
    });
  }
}
{{#each this.items as |item|}}
  {{#if (eq item.type 'foo') }}

    <item.component @arg1={{item.name}} @arg2={{item.type}} />

  {{else}}

    <item.component @otherArgs={{item.name}} />

  {{/if}}
{{/each}}