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

Component helper in JS #434

Closed
mehulkar opened this issue Jan 19, 2019 · 24 comments
Closed

Component helper in JS #434

mehulkar opened this issue Jan 19, 2019 · 24 comments
Labels
T-framework RFCs that impact the ember.js library

Comments

@mehulkar
Copy link
Contributor

mehulkar commented Jan 19, 2019

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
Copy link

sdhull commented Jan 25, 2019

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
Copy link

sdhull commented Jan 25, 2019

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
Copy link
Member

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
Copy link

Herriau commented Jan 25, 2019

@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
Copy link
Contributor Author

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
Copy link
Member

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
Copy link

Herriau commented Jan 25, 2019

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
Copy link
Member

@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
Copy link

Herriau commented Jan 30, 2019

@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
Copy link
Contributor

@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
Copy link
Contributor Author

mehulkar commented Jun 4, 2020

@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
Copy link
Contributor

ef4 commented Oct 22, 2020

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
Copy link

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

@pzuraq
Copy link
Contributor

pzuraq commented Oct 28, 2020

@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
Copy link
Contributor

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
Copy link
Contributor

ef4 commented Jul 9, 2021 via email

@NullVoxPopuli
Copy link
Contributor

🤔 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
Copy link
Contributor

pzuraq commented Jul 12, 2021

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
Copy link
Contributor

ef4 commented Jul 12, 2021

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
Copy link
Contributor

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
Copy link
Contributor

pzuraq commented Jul 12, 2021

@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
Copy link
Member

wagenet commented Jul 23, 2022

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!

@wagenet wagenet closed this as completed Jul 23, 2022
@NullVoxPopuli
Copy link
Contributor

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}}

@davidtaylorhq
Copy link

I've implemented a curryComponent function here: https://github.com/davidtaylorhq/ember-curry-component. It can be used in JS, or as a helper inside <template> tag. The Readme has examples, including two different patterns for reactivity.

The implementation is only a few lines long, and follows the same patterns Ember core uses to curry components in JS. It's using only 'public' APIs on @glimmer/*.

Open to suggestions for API improvements! If people find it useful, perhaps we can reboot the discussions about including something like this in Ember itself.

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

No branches or pull requests