rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

Introduce Template-only Class Components

Open NullVoxPopuli opened this issue 4 months ago • 12 comments

Propose Template Only Class Components

Rendered

Summary

This pull request is proposing a new RFC.

To succeed, it will need to pass into the Exploring Stage, followed by the Accepted Stage.

A Proposed or Exploring RFC may also move to the Closed Stage if it is withdrawn by the author or if it is rejected by the Ember team. This requires an "FCP to Close" period.

An FCP is required before merging this PR to advance to Accepted.

Upon merging this PR, automation will open a draft PR for this RFC to move to the Ready for Released Stage.

Exploring Stage Description

This stage is entered when the Ember team believes the concept described in the RFC should be pursued, but the RFC may still need some more work, discussion, answers to open questions, and/or a champion before it can move to the next stage.

An RFC is moved into Exploring with consensus of the relevant teams. The relevant team expects to spend time helping to refine the proposal. The RFC remains a PR and will have an Exploring label applied.

An Exploring RFC that is successfully completed can move to Accepted with an FCP is required as in the existing process. It may also be moved to Closed with an FCP.

Accepted Stage Description

To move into the "accepted stage" the RFC must have complete prose and have successfully passed through an "FCP to Accept" period in which the community has weighed in and consensus has been achieved on the direction. The relevant teams believe that the proposal is well-specified and ready for implementation. The RFC has a champion within one of the relevant teams.

If there are unanswered questions, we have outlined them and expect that they will be answered before Ready for Release.

When the RFC is accepted, the PR will be merged, and automation will open a new PR to move the RFC to the Ready for Release stage. That PR should be used to track implementation progress and gain consensus to move to the next stage.

Checklist to move to Exploring

  • [ ] The team believes the concepts described in the RFC should be pursued.
  • [ ] The label S-Proposed is removed from the PR and the label S-Exploring is added.
  • [ ] The Ember team is willing to work on the proposal to get it to Accepted

Checklist to move to Accepted

  • [ ] This PR has had the Final Comment Period label has been added to start the FCP
  • [ ] The RFC is announced in #news-and-announcements in the Ember Discord.
  • [ ] The RFC has complete prose, is well-specified and ready for implementation.
    • [ ] All sections of the RFC are filled out.
    • [ ] Any unanswered questions are outlined and expected to be answered before Ready for Release.
    • [ ] "How we teach this?" is sufficiently filled out.
  • [ ] The RFC has a champion within one of the relevant teams.
  • [ ] The RFC has consensus after the FCP period.

NullVoxPopuli avatar Aug 14 '25 16:08 NullVoxPopuli

Is there a good reason to introduce a new concept instead of detecting if a standard component has no methods/properties except for the template and doing the optimisation to turn it into this?

var Demo = template(`{{@value}}`, { scope: () => ({}) });

evoactivity avatar Aug 14 '25 16:08 evoactivity

I was thinking the same thing as @evoactivity. Would be cool if we eliminated the template only paradigm entirely, everything is class based, then the class is optimized away when not needed. This is clearly way more complicated on the build, but I've heard from more than one person that the ember/no-empty-glimmer-component-classes rule is annoying as you don't really know when you get started with a component if you're going to need the backing class or not and there is a temptation to disable that rule.

This rule is currently the only thing preventing me from doing this accidentally, and I'm glad for the rule, but it does point to a place where the mental model for components conflicts with the realities of build and performance. This RFC seems to point the same way.

jrjohnson avatar Aug 14 '25 17:08 jrjohnson

I mean, I think that's a great idea, and it means we only need to teach about a "for fun" detail that folks normally don't have to worry about.

I like that better than what's written here and will update the RFC text to reflect

NullVoxPopuli avatar Aug 14 '25 17:08 NullVoxPopuli

Actually, I don't think that's possible without a breaking change.

Consider:

class A extends Component {
  <template>
     <B @state={{this}} />
  </template>
}

class B extends Component {
  get router() {
    return getOwner(this.args.state).lookup('...');
  }
  <template>
    {{this.router.currentURL}}
  </template>
}

Now... should someone do this? absolutely not. lol But folks could be doing it today, for less silly reasons than the above example, and converting class A to a template-only component would break their apps.

So naturally, we could say something more specific about the logic that governs when class A would be converted to a template only component -- by saying that this cannot be referenced in the template as well.

This requires that we fully parse the template contents during compilation -- which we currently don't do. The conversions to low-level template() format for A would look like this:

class A {
  static {
    template(`<B @state={{this}} />`, { 
      component: this,
      scope: () => ({ B }) 
    });
  }
}

as proposed, we would ignore the component: this line, that's fine, but normally we leave the first arg passed to template() a string (for libraries).

So, this means only applications would get this optimization, as only in applications (where we would parse and compile the template anyway), could we determine if a this Path exists, and then decide to replace the whole class with a var A = template(....)

Is this an ok trade-off?

NullVoxPopuli avatar Aug 14 '25 18:08 NullVoxPopuli

Would be cool if we eliminated the template only paradigm entirely

@jrjohnson I don't think we need to elimate the paradigm entirely since this is still useful

const rowComp = <template><div>I'm row {{@data}}</div></template>; // <-- template only

class ThingWithRows extends Component {
  rows = [...Array(20).keys()].map( i => i+1); ;

  <template>
    {{#each rows as |row|}}
      <rowComp @data={{row}} />
    {{/each}}
  </template>
}

This requires that we fully parse the template contents during compilation -- which we currently don't do.

@NullVoxPopuli Could we just look in the string for any occurrence of this inside of {{ }} using regex to avoid parsing it fully? If not the trade off sounds reasonable.

evoactivity avatar Aug 14 '25 19:08 evoactivity

I'm ok with this tradeoff, mainly because I think if you're authoring an add-on or some other way to include components into the application it's reasonable to assume you are willing to learn more stuff. But this difference between authoring an app and an add-on can get really difficult quickly. So it's probably not a great path to take unless we can lint the difference and encourage the behavior needed.

~I'm also wondering if we need to look all the way into the <template>. I'm not sure, but would it be possible to determine if a component class only included the template itself and nothing else? Would this make it possible to eliminate the difference and the tradeoff?~ edit: ah I see, you're passing this elsewhere. Got it.

We certainly don't need to eliminate the const style, but I'd say that the template only const declaration is instead an additional authoring method for that special case and completely available if needed in other places. This is the inverse of the way Octane had us thinking (start with his), but I like the concept for teaching. You always start with a class and then you can add const template stuff as well.

jrjohnson avatar Aug 14 '25 20:08 jrjohnson

Could we just look in the string for any occurrence of this inside of {{ }} using regex to avoid parsing it fully?

I thought about that as well, but regex is hard, and it's why the v3 version of ember-template-imports was so buggy.

Situations that'd be hard to deal with with regex:

  • {{
     this
    
    
    }}
    
  • {{concat "}}" this }}
    
  • @arg={{"this"}}
    

NullVoxPopuli avatar Aug 14 '25 20:08 NullVoxPopuli

@NullVoxPopuli this monstrosity seems to work in many cases but I take your point

{{(?=(?:[^"'{}]|"[^"]*"|'[^']*')*?\bthis\b(?:[^"'{}]|"[^"]*"|'[^']*')*?\}\})(?:[^"'{}]|"[^"]*"|'[^']*')*?\bthis\b(?:[^"'{}]|"[^"]*"|'[^']*')*?}}

https://regex101.com/r/hD8UqN/3

evoactivity avatar Aug 14 '25 20:08 evoactivity

Broke it for you :p https://regex101.com/r/aw8K9W/1

NullVoxPopuli avatar Aug 14 '25 20:08 NullVoxPopuli

I fixed those cases but it's even worse now. Not exactly maintainable. I will code golf this no more 😄 https://regex101.com/r/aw8K9W/2

evoactivity avatar Aug 14 '25 20:08 evoactivity

wrote up a new one: https://github.com/emberjs/rfcs/pull/1134

NullVoxPopuli avatar Aug 14 '25 21:08 NullVoxPopuli

potential alternative:

// current: no generics!
export const Demo = <template>...</template> 

// what Glint would want
// but doing this would require a component manager that hijacks all functions in component space
// (or we compile it away and forbid anything other than the <template></template>)
export function Demo<T>() {
  return template(``)
}

// but our runtime is the equiv of
export const Demo = template('...');

// author-time???
export const Demo = <template type:{
  <T extends Record<string, unknown>, K extends keyof T>() => {
    Element: HTMLTextAreaElement;
    Args: {
      foo: T;
      bar: K;
    },
    Blocks: {
      default: []
    }
  }
}>...</template>

NullVoxPopuli avatar Aug 15 '25 18:08 NullVoxPopuli