From 94882b128e9cbab0240196586da01d2beffd8acc Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Wed, 12 May 2021 13:28:55 +0200 Subject: [PATCH 01/13] Richer @glimmer/component type information --- text/0000-glimmer-component-signature.md | 374 +++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 text/0000-glimmer-component-signature.md diff --git a/text/0000-glimmer-component-signature.md b/text/0000-glimmer-component-signature.md new file mode 100644 index 0000000000..0445197fab --- /dev/null +++ b/text/0000-glimmer-component-signature.md @@ -0,0 +1,374 @@ +--- +Stage: +Start Date: +Release Date: Unreleased +Release Versions: + ember-source: vX.Y.Z + ember-data: vX.Y.Z +Relevant Team(s): Ember.js +RFC PR: +--- + +## Summary + +In TypeScript, the `@glimmer/component` base class currently has a single `Args` type parameter. This parameter declares the names and types of the arguments the component expects to receive. + +This RFC proposes a change to that type parameter to become `Signature`, capturing more complete information about how components can be used in a template, including their expected **arguments**, the **block parameters** they provide, and what type of **element(s)** they apply any received attributes and modifiers to. + +This RFC is based in large part on prior work by [@gossi] and on learnings from [Glint]. + +[@gossi]: https://github.com/gossi +[Glint]: https://github.com/typed-ember/glint + +## Motivation + +When a developer goes to invoke a component in a template, there are a variety of questions about its interface they need to be able to answer in order to determine how to use it correctly. + +1. What arguments does this component accept? + - Which arguments are required and which are optional? + - What does each argument do? + - What types of values am I expected to pass for a given argument? +2. How can I nest content within this component? + - Does it accept a default block? Named blocks? + - In what context will my block content appear in the component's output? + - For any blocks I write, what parameters does the component expose to them? +3. In what ways can I treat this component like a regular HTML/SVG element? + - Can I pass attributes to it? + - If I apply a modifier, what kind(s) of DOM `Element` object will the modifier see? + +In the Glimmer `Component` base class today, a TypeScript component author can answer the first bucket of questions above by defining an `Args` type for the component class. Using this structured documentation, developer tooling can surface information about a component's arguments to consumers without requiring them to go read either ad-hoc prose or the implementation itself to determine the component's intended usage. + +For the second and third sets of questions, however, there is no such affordance for providing structured answers. + +The goal of this change is to enable tooling to provide a more complete picture for developers of the ways in which Glimmer components can be used. The word "tooling" here is used in broad terms, including: + - API/design system documentation generators, like [Storybook] or [Hokulea] + - Language servers that provide hover information, go-to-definition, etc. like [TypeScript] or [uELS] + - Template-aware static analysis and typechecking systems, like [Glint] or [`els-addon-typed-templates`] + +[Storybook]: https://storybook.js.org/ +[Hokulea]: https://github.com/hokulea/hokulea +[TypeScript]: https://www.typescriptlang.org/ +[uELS]: https://github.com/lifeart/ember-language-server +[Glint]: https://github.com/typed-ember/glint +[`els-addon-typed-templates`]: https://github.com/lifeart/els-addon-typed-templates + +## Detailed design + +Given a component with this template: + +```hbs +
+ {{yield this.dismiss}} +
+``` + +This RFC proposes that a TypeScript backing class currently written like this: + +```ts +import Component from '@glimmer/component'; + +export interface NoticeArgs { + /** The kind of message displayed in this notice. Defaults to `info`. */ + kind: 'error' | 'warning' | 'info' | 'success'; +} + +export default class Notice extends Component { + // ... +} +``` + +Would instead, if fully typed, be written like this: + +```ts +import Component from '@glimmer/component'; + +export interface GreetingSignature { + Element: HTMLDivElement; + Args: { + /** The kind of message displayed in this notice. Defaults to `info`. */ + kind: 'error' | 'warning' | 'info' | 'success'; + }; + BlockParams: { + /** + * Any default block content will be displayed in the body of the notice. + * This block receives a single `dismiss` parameter, which is an action + * that can be used to dismiss the notice. + */ + default: [dismiss: () => void]; + }; +} + +export default class Greeting extends Component { + // ... +} +``` + +By nesting the existing `Args` type under a top-level _signature_ type, we create a place to provide additional type information for other aspects of a component's behavior. One key element of this design is that eases future evolution of the signature, as including additional keys with new meaning won't be a breaking change. + +In addition to `Args`, two other signature members are proposed here: `BlockParams` and `Element`. A detailed explanation of each of these follows. + +### `Args` + +The `Args` signature member works exactly as the top-level `Args` type parameter does in `@glimmer/component@1.x` prior to this RFC. That is, it determines the type of `this.args` in the component backing class, as well as acting as an anchor for human-readable documentation for specific arguments. + +A component that accepts no arguments can omit the `Args` key from its signature. + +### `BlockParams` + +The `BlockParams` member dictates what blocks a component accepts and specifies what parameters, if any, it provides to those blocks. + +The [yieldable named blocks RFC] and recent versions of the [Component guides] discuss blocks in some depth, but since named blocks in particular are relatively new to the community, brief definitions based on those in [RFC 678] are included here for clarity. + +[yieldable named blocks RFC]: https://github.com/emberjs/rfcs/blob/master/text/0460-yieldable-named-blocks.md +[Component guides]: https://guides.emberjs.com/release/components/block-content/ +[RFC 678]: https://github.com/emberjs/rfcs/pull/678 + +
+ Blocks, Block Parameters and Yielding + + - **Block** + + A block is a section of content that a template author provides to a component when invoking it. Many components accept a _default_ block, and components invoked using curly braces may also accept an _else_ block. + + ```hbs + + This is a default block. + + + {{#if-a-coin-flip-is-heads}} + This is also a default block. + {{else}} + This is an else block. + {{/if-a-coin-flip-is-heads}} + ``` + + For angle-bracket components, the above example with an _implicit_ default block could also be written with an _explicit_ one: + + ```hbs + + <:default>This is another default block. + + ``` + + This syntax, using `<:identifier>` to delimit block contents, allows authors to provide one or more _named blocks_ to a component: + + ```hbs + + <:header> + This is the header block. + + <:body> + This is the body block. + + + ``` + + - **Block Parameters** + + Blocks may also receive _parameters_ from the component that they're provided to, using `as |identifier ...|` syntax. + + For an implicit default block, the parameters are exposed from the top level component: + + ```hbs + + + + ``` + + For named blocks, different parameters may be exposed individually to each block: + + ```hbs + + <:header>Close me + <:body as |close|> + + + + ``` + + - **Yield** + + Yielding is how a component invokes a provided block, optionally exposing block parameters. An example from [RFC 460](https://emberjs.github.io/rfcs/0460-yieldable-named-blocks.html#block-parameters): + + ```hbs +
+
{{yield @article.title to='header'}}
+
{{yield @article.body to='body'}}
+
+ ``` +
+ +The `BlockParams` type maps the block names a component accepts to a tuple type representing the parameters those blocks will receive. A component that never yields to any blocks may omit the `BlockParams` key from its signature. + +As a concrete example, the `BlogPost` component in the [Block Parameters section] of the Ember guides looks like this: + +[Block Parameters section]: https://guides.emberjs.com/release/components/block-content/#toc_block-parameters + +```hbs +{{yield @post.title @post.author @post.body}} +``` + +```hbs + + + + + {{postTitle}} + + {{postBody}} + + + +``` + +The signature for this component might look like: + +```ts +export interface BlogPostSignature { + Args: { post: Post }; + BlockParams: { + default: [postTitle: string, postAuthor: string, postBody: string]; + }; +} +``` + +Block parameters may also depend on the types of args a component receives. For example, for a fancy table component: + +```hbs + + <:header> + Name + Age + + + <:row as |person|> + {{person.name}} + {{person.age}} + + +``` + +The backing class and signature might look something like: + +```ts +export interface FancyTableSignature { + Args: { + /** The items to be displayed in the fancy table, each corresponding to one row. */ + items: Array + }; + BlockParams: { + /** Any header contents for the table, broken into cells. */ + header: []; + + /** Content to be rendered for each row, receiving the corresponding item and its index. */ + row: [item: T, index: number]; + }; +} + +export default class FancyTable extends Component> { + // ... +} +``` + +### `Element` + +The `Element` member of the signature declares what type of DOM element(s), if any, this component may be treated as encapsulating. That is, setting a non-`null` type for this member declares that this component may have HTML attributes applied to it, and that type reflects the type of DOM `Element` object any modifiers applied to the component will receive when they execute. Omitting `Element` or explicitly declaring it as `null` indicates that a component does not accept HTML attributes and modifiers at all. + +For example, [`{{animated-if}}`] would omit `Element` from its signature, as it emits no DOM content. Even if you invoked it with angle-bracket syntax, any attributes or modifiers you applied wouldn't go anywhere. + +On the other hand, [``] would set `Element: HTMLImageElement`, as the element in its template that it ultimately spreads `...attributes` on to is an ``. + +While it's not common, occasionally components might forward `...attributes` to different types of elements in their template: + +```hbs +{{#if @destination}} + {{yield}} +{{else}} + {{yield}} +{{/if}} +``` + +For such cases, components can use a union type for their `Element`. In the case of the template above, the signature would have `Element: HTMLAnchorElement | HTMLSpanElement`. + +[`{{animated-if}}`]: https://ember-animation.github.io/ember-animated/docs/api/components/animated-if +[``]: https://github.com/kaliber5/ember-responsive-image#the-responsiveimage-component + +## How we teach this + +### Documentation + +TypeScript is [not (currently) officially supported by Ember](https://github.com/emberjs/rfcs/pull/724), and as such none of the framework guides or API documentation mention the existing `Args` type parameter today. The upshot of this is that, while the `Args` type parameter is well known by the portion of the Ember community that works with TypeScript, there's no official documentation to be updated. + +The [`ember-cli-typescript` website], however, does have a section on working with components in TypeScript that deals almost exclusively with the `Args` parameter and `this.args` on the backing class. We should update and expand this documentation to cover the other concepts included in the `Signature` type. + +[`ember-cli-typescript` website]: https://docs.ember-cli-typescript.com/ember/components#understanding-args + +### Naming + +The `Signature` concept itself has been [used in Glint] and broadly well received and understood, but the naming of each of the three member elements has received some discussion. While the names proposed above are what we currently believe to be best suited from both pedagogical and API-consistency perspectives, we're certainly interested in community feedback at this stage. + +[used in Glint]: https://github.com/typed-ember/glint#component-signatures + +#### `Args` + +Early discussions have largely been on board with `Args` as it stands, though the non-abbreviated option `Arguments` has been suggested as an alternative. Since `Args` aligns better with `this.args` on the backing class (as well as the way in which people often colloquially discuss `@arg` values), we're continuing to propose `Args` here. + +#### `Element` + +Among early adopters of Glint there has been some confusion as to what the purpose of this key is. Generally "it's the concrete place your `...attributes` ultimately land after being passed down through components" has worked as an explainer, but the fact that an explainer is needed may indicate that `Element` isn't a clear name on its own. + +That said, even among those who were initially unclear what the purpose of `Element` was, no one has been able to come up with an alternative proposal. Attempts at more explicit formulations like `UltimateSplattributesTarget` don't quite roll off the tongue 🙂 + +#### `BlockParams` + +This key has easily been the largest topic of discussion among the three `Signature` members. Currently Glint uses `Yields` for this concept, but developers have given consistent feedback that that doesn't fit with `Args` and `Element` in their mental model. + +`Blocks` has been the most commonly-suggested alternative, but that doesn't quite align with the information being captured. Blocks are the chunks of content passed _in_ to a component by the author invoking it, while this signature member captures what parameters (if any) the component will pass _out_ to those blocks. + +For all three signature members proposed here, Glint will align its API with whatever terminology this RFC lands on. + +### Migration + +We can implement this change in `@glimmer/component` 1.x in a backwards compatible way, allowing for a deprecation period before moving exclusively to the signature approach in 2.0. Because capitalized arg names are currently illegal, any valid signature will not represent valid arg names, so the backing class can [accept both formats](https://tsplay.dev/Nr2rzN) and distinguish which was intended. + +Ideally we'll be able to encourage authors to migrate their usage of `Args` in the same ways they're used to being nudged to move away from other deprecated APIs in the Ember ecosystem. + +#### Codemods + +A simple codemod to turn `Component` into `Component<{ Args: MyArgs }>` would be straightforward, but it may also be feasible to migrate more completely by inferring information like block names and where `...splattributes` are used from colocated templates. The richness we pursue here will likely depend on the appetite of the community to explore what's possible. + +#### Deprecation Warnings/Linting + +While type-only deprecations aren't something the Ember ecosystem has dealt with much previously, it's a muscle we may want to begin building as [official TypeScript support] is under consideration. + +The `@typescript-eslint` suite of packages supports writing [type-aware rules] in projects that provide appropriate configuration in their `eslintrc`, so one option available to use would be to use that functionality to allow users to lint for components that haven't yet been migrated to use a signature type. We could expose this as part of a standalone ESLint plugin, or perhaps make it available on an opt-in basis in `eslint-plugin-ember`. + +[official TypeScript support]: https://github.com/emberjs/rfcs/pull/724 +[type-aware rules]: https://github.com/typescript-eslint/typescript-eslint#can-we-write-rules-which-leverage-type-information + +## Drawbacks + +As with any change that deprecates supported behavior, there's an inherent cost associated with migrating the user base over to new patterns. One goal of this change, however, is to ease such migrations in the future: as the templating system evolves and new information becomes relevant to capture and document, the `Signature` type provides a place for that information to live without disrupting existing code. + +The other potential drawback to this approach is that it introduces TypeScript type information that has no visible effect on the component using out-of-the-box tooling. Without a template-aware system like Glint or `els-addon-typed-templates` for validating components, nothing enforces that the signature declared is actually accurate. See the "Only `Args`" section under Alternatives below for further discussion of this point. + +## Alternatives + +### Additional Positional Type Parameters + +Rather than wrapping the information we're interested in capturing in a `Signature` type, we could instead introduce further type parameters to the `Component` base class: + +```ts +class Component { + // ... +} +``` + +This has the advantage of not requiring current users of the `Args` parameter to change their code, but suffers many of the same ergonomic issues as regular functions do when they begin to accrue many positional parameters. Authors need to remember which parameters appear in which order when using the `Component` type, and they need to be aware of the appropriate default values for earlier parameters to fill in when they only want to specify a later one. + + +### Only `Args` + +One of the drawbacks mentioned above is that the `Element` and `BlockParams` signature members described in this RFC are functionally inert in a vanilla TypeScript project. This leaves them with about the same status as comments: potentially helpful when left by a well-meaning author, but without any checks to ensure that they're accurate and that they stay up-to-date as the implementation changes. + +An alternative would be to still introduce the `Signature` type in `@glimmer/component` but _only_ formalize the `Args` member, leaving other tooling to define the semantics of any additional signature members they might be interested in. While this would simplify the overall proposal somewhat, what a component `{{yield}}`s and what it does with its `...attributes` are core enough to a component's public interface that we believe they should be considered first-class rather than having individual tools reinvent them, potentially in mutually-incompatible ways. From a40f3b0b08fc4b02821695dc93f005210b8d2217 Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Thu, 13 May 2021 15:33:36 +0200 Subject: [PATCH 02/13] Add PR info --- ...onent-signature.md => 0748-glimmer-component-signature.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename text/{0000-glimmer-component-signature.md => 0748-glimmer-component-signature.md} (99%) diff --git a/text/0000-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md similarity index 99% rename from text/0000-glimmer-component-signature.md rename to text/0748-glimmer-component-signature.md index 0445197fab..0aee14f07e 100644 --- a/text/0000-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -1,12 +1,12 @@ --- Stage: -Start Date: +Start Date: 2021-05-13 Release Date: Unreleased Release Versions: ember-source: vX.Y.Z ember-data: vX.Y.Z Relevant Team(s): Ember.js -RFC PR: +RFC PR: https://github.com/emberjs/rfcs/pull/748 --- ## Summary From 87bd342e9613c79dfc609699339722f22439a148 Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Thu, 13 May 2021 15:59:46 +0200 Subject: [PATCH 03/13] Fix typo from an earlier draft --- text/0748-glimmer-component-signature.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 0aee14f07e..168850f6bf 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -82,7 +82,7 @@ Would instead, if fully typed, be written like this: ```ts import Component from '@glimmer/component'; -export interface GreetingSignature { +export interface NoticeSignature { Element: HTMLDivElement; Args: { /** The kind of message displayed in this notice. Defaults to `info`. */ @@ -98,7 +98,7 @@ export interface GreetingSignature { }; } -export default class Greeting extends Component { +export default class Notice extends Component { // ... } ``` From 928a800843a8b604a60b483927b467885006cf0d Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Fri, 21 May 2021 14:18:50 -0600 Subject: [PATCH 04/13] Demonstrate the rough shape of a signature up front --- text/0748-glimmer-component-signature.md | 32 ++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 168850f6bf..05fd6bc236 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -54,7 +54,23 @@ The goal of this change is to enable tooling to provide a more complete picture ## Detailed design -Given a component with this template: +This RFC proposes the structure for a `Signature` type that enables authors to answer the questions outlined above about their component. + +```ts +interface Signature { + Args?: { + argName: ArgType; + // ... + }; + BlockParams?: { + blockName: [param1: Param1Type, paramTwo: Param2Type, ...]; + // ... + }; + Element?: AnElementType | null; +} +``` + +So, given a component with this template: ```hbs
@@ -62,7 +78,7 @@ Given a component with this template:
``` -This RFC proposes that a TypeScript backing class currently written like this: +A TypeScript backing class currently written like this: ```ts import Component from '@glimmer/component'; @@ -105,18 +121,18 @@ export default class Notice extends Component { By nesting the existing `Args` type under a top-level _signature_ type, we create a place to provide additional type information for other aspects of a component's behavior. One key element of this design is that eases future evolution of the signature, as including additional keys with new meaning won't be a breaking change. -In addition to `Args`, two other signature members are proposed here: `BlockParams` and `Element`. A detailed explanation of each of these follows. - ### `Args` The `Args` signature member works exactly as the top-level `Args` type parameter does in `@glimmer/component@1.x` prior to this RFC. That is, it determines the type of `this.args` in the component backing class, as well as acting as an anchor for human-readable documentation for specific arguments. -A component that accepts no arguments can omit the `Args` key from its signature. +A signature with no `Args` indicates that its component does not accept any arguments. ### `BlockParams` The `BlockParams` member dictates what blocks a component accepts and specifies what parameters, if any, it provides to those blocks. +A signature with no `BlockParams` indicates that its component never yields to any blocks. + The [yieldable named blocks RFC] and recent versions of the [Component guides] discuss blocks in some depth, but since named blocks in particular are relatively new to the community, brief definitions based on those in [RFC 678] are included here for clarity. [yieldable named blocks RFC]: https://github.com/emberjs/rfcs/blob/master/text/0460-yieldable-named-blocks.md @@ -198,7 +214,7 @@ The [yieldable named blocks RFC] and recent versions of the [Component guides] d ``` -The `BlockParams` type maps the block names a component accepts to a tuple type representing the parameters those blocks will receive. A component that never yields to any blocks may omit the `BlockParams` key from its signature. +The `BlockParams` type maps the block names a component accepts to a tuple type representing the parameters those blocks will receive. As a concrete example, the `BlogPost` component in the [Block Parameters section] of the Ember guides looks like this: @@ -272,7 +288,9 @@ export default class FancyTable extends Component> { ### `Element` -The `Element` member of the signature declares what type of DOM element(s), if any, this component may be treated as encapsulating. That is, setting a non-`null` type for this member declares that this component may have HTML attributes applied to it, and that type reflects the type of DOM `Element` object any modifiers applied to the component will receive when they execute. Omitting `Element` or explicitly declaring it as `null` indicates that a component does not accept HTML attributes and modifiers at all. +The `Element` member of the signature declares what type of DOM element(s), if any, this component may be treated as encapsulating. That is, setting a non-`null` type for this member declares that this component may have HTML attributes applied to it, and that type reflects the type of DOM `Element` object any modifiers applied to the component will receive when they execute. + +A signature with no `Element` or with `Element: null` indicates that its component does not accept HTML attributes and modifiers at all. For example, [`{{animated-if}}`] would omit `Element` from its signature, as it emits no DOM content. Even if you invoked it with angle-bracket syntax, any attributes or modifiers you applied wouldn't go anywhere. From 0cf2232b14e3b96a9ffb4107776b775c671544ef Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Thu, 24 Jun 2021 17:36:47 +0200 Subject: [PATCH 05/13] Incorporate framework core feedback --- text/0748-glimmer-component-signature.md | 109 ++++++++++++++++++----- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 05fd6bc236..1cd8934db5 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -1,5 +1,5 @@ --- -Stage: +Stage: Proposed Start Date: 2021-05-13 Release Date: Unreleased Release Versions: @@ -62,10 +62,7 @@ interface Signature { argName: ArgType; // ... }; - BlockParams?: { - blockName: [param1: Param1Type, paramTwo: Param2Type, ...]; - // ... - }; + BlockParams?: [param1: Param1Type, paramTwo: Param2Type, /* ... */]; Element?: AnElementType | null; } ``` @@ -104,14 +101,12 @@ export interface NoticeSignature { /** The kind of message displayed in this notice. Defaults to `info`. */ kind: 'error' | 'warning' | 'info' | 'success'; }; - BlockParams: { - /** - * Any default block content will be displayed in the body of the notice. - * This block receives a single `dismiss` parameter, which is an action - * that can be used to dismiss the notice. - */ - default: [dismiss: () => void]; - }; + /** + * Any default block content will be displayed in the body of the notice. + * This block receives a single `dismiss` parameter, which is an action + * that can be used to dismiss the notice. + */ + BlockParams: [dismiss: () => void]; } export default class Notice extends Component { @@ -129,7 +124,32 @@ A signature with no `Args` indicates that its component does not accept any argu ### `BlockParams` -The `BlockParams` member dictates what blocks a component accepts and specifies what parameters, if any, it provides to those blocks. +The `BlockParams` member dictates what blocks a component accepts and specifies what parameters, if any, it provides to those blocks. When `BlockParams` is a simple tuple type, that indicates that the component only yields to the default block: + +```ts +BlockParams: [name: string]; +``` + +```hbs +{{yield "Tomster"}} +``` + +However, `BlockParams` may also be an object type indicating what parameters a component's named blocks provide: + +```ts +BlockParams: { + header: []; + body: [item: T; index: number] +} +``` + +```hbs +{{yield to="header"}} + +{{#each items as |item index|}} + {{yield item index to="body"}} +{{/each}} +``` A signature with no `BlockParams` indicates that its component never yields to any blocks. @@ -242,9 +262,7 @@ The signature for this component might look like: ```ts export interface BlogPostSignature { Args: { post: Post }; - BlockParams: { - default: [postTitle: string, postAuthor: string, postBody: string]; - }; + BlockParams: [postTitle: string, postAuthor: string, postBody: string]; } ``` @@ -286,16 +304,34 @@ export default class FancyTable extends Component> { } ``` +While the runtime design of named blocks currently only permits components to yield parameters out to them, community members have suggested possible ways of making blocks more akin to components themselves, potentially accepting `@args` or attributes, or even themselves accepting further nested blocks. Should such a design ever become reality, the shape of a signature might evolve to become more recursive. Today, however, there are many open questions about how such functionality would actually work for authors, and so we keep the proposed format here simple. + ### `Element` The `Element` member of the signature declares what type of DOM element(s), if any, this component may be treated as encapsulating. That is, setting a non-`null` type for this member declares that this component may have HTML attributes applied to it, and that type reflects the type of DOM `Element` object any modifiers applied to the component will receive when they execute. -A signature with no `Element` or with `Element: null` indicates that its component does not accept HTML attributes and modifiers at all. +A signature with no `Element` or with `Element: null` indicates that its component does not accept HTML attributes and modifiers at all. While applying attributes or modifiers to such a component wouldn't produce a runtime error, it still likely constitutes a mistake on the author's part, similar to [passing an unknown key in an object literal][ecp]. For example, [`{{animated-if}}`] would omit `Element` from its signature, as it emits no DOM content. Even if you invoked it with angle-bracket syntax, any attributes or modifiers you applied wouldn't go anywhere. On the other hand, [``] would set `Element: HTMLImageElement`, as the element in its template that it ultimately spreads `...attributes` on to is an ``. +[`{{animated-if}}`]: https://ember-animation.github.io/ember-animated/docs/api/components/animated-if +[``]: https://github.com/kaliber5/ember-responsive-image#the-responsiveimage-component + +The `Element` member is of particular relevance for the modifiers that consumers can apply to a component. In a system using this information to provide typechecking, any modifiers applied to its component must be declared to accept the component's `Element` type (or a broader type) as its first parameter, or else produce a type error. + +- A component with `Element: Element` can only be used with modifiers that accept _any_ DOM element. Many existing modifiers in the ecosystem, such as `{{on}}` and everything in `ember-render-modifiers`, fall into this bucket. + +- A component with e.g. `Element: HTMLCanvasElement`, may be used with any general-purpose modifiers as described above _as well as_ any modifiers that specifically expect to be attached to a ``. + +- A component whose `Element` type is a [union of multiple possible elements](#components-with-multiple-or-varying-elements) can only be used with a modifier that is declared to accept _all_ of those element types. This behavior is, in fact, the point—modifiers are essentially callbacks that receive the element they're attached to, and so the [normal considerations][variance] for typing callback parameters apply. + +[ecp]: https://www.typescriptlang.org/docs/handbook/interfaces.html#excess-property-checks +[variance]: https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science) + +#### Components With Multiple or Varying Elements + While it's not common, occasionally components might forward `...attributes` to different types of elements in their template: ```hbs @@ -306,10 +342,41 @@ While it's not common, occasionally components might forward `...attributes` to {{/if}} ``` -For such cases, components can use a union type for their `Element`. In the case of the template above, the signature would have `Element: HTMLAnchorElement | HTMLSpanElement`. +For such cases, components can use a union type for their `Element`. In the case of the template above, the signature would have `Element: HTMLAnchorElement | HTMLSpanElement`. Correspondingly, any modifiers used with such components would need to accept any of the possible types of DOM elements declared. -[`{{animated-if}}`]: https://ember-animation.github.io/ember-animated/docs/api/components/animated-if -[``]: https://github.com/kaliber5/ember-responsive-image#the-responsiveimage-component +Similarly, a component that may use `...attributes` on an `` element or may not spread them at all might write: `Element: HTMLAnchorElement | null`. In such cases, it would be legal to use any modifiers that accept an `HTMLAnchorElement`, since they wouldn't ever be invoked for the `null` scenario. + +In cases where the distinction between possible elements is key to the functionality of the component and can be statically known based on the arguments passed in, the component author may choose to capture this as part of the signature at the expense of additional type-level bookkeeping. + +
Gritty details +The particular shape/value of arguments is something that varies from instance to instance of the component, and the standard tool in TypeScript for handling these cases is to introduce a _type parameter_ on the type(s) in question. + +For the `{{#if @destination}}` example above, the implementation might look like this: + +```ts +interface MaybeLinkSignature { + Args: { + destination: Destination; + target?: string; + }; + BlockParams: []; + Element: Destination extends string + ? HTMLAnchorElement + : HTMLSpanElement; +} + +export default class MaybeLink + extends Component> { + // ... +} +``` + +This would allow consumers to use a modifier that requires an `HTMLAnchorElement` on a `MaybeLink` if and only if the `@destination` arg they pass is definitely a string. + +Note, still, that general-purpose modifiers like `{{on}}` or `{{did-insert}}` would be usable with this component regardless of the type of `@destination`, or even if the author had simply typed `Element` as `HTMLAnchorElement | HTMLSpanElement` without the extra song-and-dance of explicitly capturing the `Destination` type. + +Finally, template analysis tools can provide escape hatches in the same vein as TypeScript's `@ts-ignore` and `@ts-expect-error` for these or any cases where consumers have information that library authors haven't encoded in the type system. +
## How we teach this From dff2502cd5d05f2376e44220e449164cff58d08b Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Fri, 25 Jun 2021 13:22:19 +0200 Subject: [PATCH 06/13] Clarity on intermingled default + named blocks --- text/0748-glimmer-component-signature.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 1cd8934db5..da372fc441 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -151,6 +151,8 @@ BlockParams: { {{/each}} ``` +If a component also accepts both a default block _and_ other named blocks, it can specify the default block by name (`default: [...]`) in its `BlockParams` in exactly the same way a consumer of the component might use `<:default>` to pass a default block alongside named ones when invoking a component. + A signature with no `BlockParams` indicates that its component never yields to any blocks. The [yieldable named blocks RFC] and recent versions of the [Component guides] discuss blocks in some depth, but since named blocks in particular are relatively new to the community, brief definitions based on those in [RFC 678] are included here for clarity. @@ -344,7 +346,7 @@ While it's not common, occasionally components might forward `...attributes` to For such cases, components can use a union type for their `Element`. In the case of the template above, the signature would have `Element: HTMLAnchorElement | HTMLSpanElement`. Correspondingly, any modifiers used with such components would need to accept any of the possible types of DOM elements declared. -Similarly, a component that may use `...attributes` on an `
` element or may not spread them at all might write: `Element: HTMLAnchorElement | null`. In such cases, it would be legal to use any modifiers that accept an `HTMLAnchorElement`, since they wouldn't ever be invoked for the `null` scenario. +Similarly, a component that may use `...attributes` on an `` element or may not spread them at all might write: `Element: HTMLAnchorElement | null`. In such cases, ecosystem tooling consuming this type information should treat it as legal to use any modifiers that accept an `HTMLAnchorElement`, since they wouldn't ever be invoked for the `null` scenario. In cases where the distinction between possible elements is key to the functionality of the component and can be statically known based on the arguments passed in, the component author may choose to capture this as part of the signature at the expense of additional type-level bookkeeping. From dd5cebf0ea8d4280b58be88a7866bf34af417933 Mon Sep 17 00:00:00 2001 From: Chris Krycho Date: Wed, 9 Mar 2022 14:48:11 -0700 Subject: [PATCH 07/13] Update with latest collab between TS and Framework - BlockParams -> Blocks - fully expanded form of signature - move naming discussion to Alternatives - add a fancy outline - add section explicitly showing update to Component type --- text/0748-glimmer-component-signature.md | 258 ++++++++++++++++++----- 1 file changed, 208 insertions(+), 50 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index da372fc441..d370fe4507 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -9,7 +9,7 @@ Relevant Team(s): Ember.js RFC PR: https://github.com/emberjs/rfcs/pull/748 --- -## Summary +## Summary In TypeScript, the `@glimmer/component` base class currently has a single `Args` type parameter. This parameter declares the names and types of the arguments the component expects to receive. @@ -20,6 +20,33 @@ This RFC is based in large part on prior work by [@gossi] and on learnings from [@gossi]: https://github.com/gossi [Glint]: https://github.com/typed-ember/glint + +### Outline + +- [Motivation](#motivation) +- [Detailed design](#detailed-design) + - [`InvokableComponentSignature`](#invokablecomponentsignature) + - [`GlimmerComponentSignature`](#glimmercomponentsignature) + - [Updated type for Glimmer `Component`](#updated-type-for-glimmer-component) + - [Example](#example) + - [`Args`](#args) + - [`Blocks`](#blocks) + - [`Element`](#element) + - [Components With Multiple or Varying Elements](#components-with-multiple-or-varying-elements) +- [How we teach this](#how-we-teach-this) + - [Documentation](#documentation) + - [Migration](#migration) + - [Codemods](#codemods) + - [Deprecation Warnings/Linting](#deprecation-warningslinting) +- [Drawbacks](#drawbacks) +- [Alternatives](#alternatives) + - [Additional Positional Type Parameters](#additional-positional-type-parameters) + - [Only `Args`](#only-args) + - [Naming](#naming) + - [`Args`](#args-1) + - [`Element`](#element-1) + - [`Blocks`](#blocks-1) + ## Motivation When a developer goes to invoke a component in a template, there are a variety of questions about its interface they need to be able to answer in order to determine how to use it correctly. @@ -41,6 +68,7 @@ In the Glimmer `Component` base class today, a TypeScript component author can a For the second and third sets of questions, however, there is no such affordance for providing structured answers. The goal of this change is to enable tooling to provide a more complete picture for developers of the ways in which Glimmer components can be used. The word "tooling" here is used in broad terms, including: + - API/design system documentation generators, like [Storybook] or [Hokulea] - Language servers that provide hover information, go-to-definition, etc. like [TypeScript] or [uELS] - Template-aware static analysis and typechecking systems, like [Glint] or [`els-addon-typed-templates`] @@ -52,22 +80,126 @@ The goal of this change is to enable tooling to provide a more complete picture [Glint]: https://github.com/typed-ember/glint [`els-addon-typed-templates`]: https://github.com/lifeart/els-addon-typed-templates + ## Detailed design -This RFC proposes the structure for a `Signature` type that enables authors to answer the questions outlined above about their component. +This RFC proposes two new TypeScript APIs: + +1. A fully general `InvokableComponentSignature` type which can capture *all* the relevant details of components in the Glimmer VM, with +2. A user-facing Glimmer Component `Signature` type that enables authors to answer the questions outlined above about their Glimmer components in a more concise and convenient way. + +Throughout, these new interfaces intentionally use `PascalCase` for names representing *types*, including types nested in interfaces: + +- to match the general TypeScript ecosystem norm that types are named in `PascalCase` +- to thereby distinguish clearly between type- and value- names in contexts which might otherwise be ambiguous +- ***to enable adopting this in a backwards-compatible way with the existing Glimmer Component type definition*** + +The third of these is the most critical, and much more strongly motivates the others. + +These interfaces are *not* importable, because importing them does not give any value to consumers: use of `extends` does not add any constraints (because of the optional-ity discussed below). + + +### `InvokableComponentSignature` + +The base `InvokableComponentSignature` type is an intentionally-verbose form, which end users will not *normally* write, but it is legal to do so. + +Two points to notice about the signature: + +1. All of the fields for what users author are optional. They are resolved into the appropriate *non*-optional representations by type machinery in Glint. For example, in a Glimmer component the `args` is never undefined; it is minimally an empty object. +2. This provides the foundation for further extensions of each of these as needed. For example, if we were to add support for named block params (in addition to today’s support for positional arguments), they could be added as a `Named` field in the interface, just like the `Positional` field present today. ```ts -interface Signature { +// A base signature type which represents items which can be invoked with +// arguments, and could be reused for helpers, modifiers, etc. +interface InvokableSignature { Args?: { - argName: ArgType; - // ... + Named?: Record; + Positional?: unknown[]; }; - BlockParams?: [param1: Param1Type, paramTwo: Param2Type, /* ... */]; - Element?: AnElementType | null; +} + +interface InvokableComponentSignature extends InvokableSignature { + // The `null` means "this does not use ...attributes" + Element?: Element | null; + Blocks?: { + [blockName: string]: { + Positional?: unknown[]; + } + } +} + +interface InvokableGlimmerComponentSignature extends InvokableComponentSignature { + Args?: { + Named?: Record; + // empty tuple here means it does not allow *any* positional params + Positional?: []; + } } ``` -So, given a component with this template: +As suggested by this lattermost form, specific component implementations may also represent a *subset* of the full signature. Future component implementations may take advantage of this just as future extensions to the system may take advantage of the ability to add more information into the signature. + + +### `GlimmerComponentSignature` + +Since the fully-expanded form is quite verbose, we also provide a much smaller interface users can write, which we expand “under the hood” into the full signature as well as onto the class body for `args`. Users are *allowed* to supply the fully-expanded form; they just do not *have* to. + +```ts +interface GlimmerComponentSignature { + Args?: { + [argName: string]: unknown; + }; + Blocks?: { + [blockName: string]: { + Params?: + | unknown[] + | { Positional: unknown[] } + } + } + Element?: Element | null; +} +``` + +As with the `InvokableComponentSignature`, the type that authors *must* write is simply empty: all the fields are optional. Once the type is attached to the component, it is resolved to the appropriate default value. For example, a component’s `args` property is *always* an object, but it may be empty. If `Args` is not supplied, it is defaulted the empty object. We cover the details of this defaulting behavior for each field below. + + +### Updated type for Glimmer `Component` + +With these signature types defined, we can update the type for the Glimmer `Component` class in a backwards-compatible way: + +- adding support for the new expanded type signature +- continuing to support all *current* uses of the type signature +- dropping the `extends` constraint, which provides little-to-no actual constraining value + +Previously: + +```ts +class Component { + readonly args: Args; +} +``` + +Updated: + +```ts +class Component { + readonly args: ComponentArgs; +} +``` + +Here, the `ComponentArgs` type will be a type utility which can resolve the named arguments to the component (the exact mechanics are an implementation detail, but you can see one possible design in [this playground][fully-working]). Users may pass any of: + +- an arguments-only definition, as has been recommended up till now in the Glimmer Component v1 era +- the `GlimmerComponentSignature` short-hand form +- the expanded `InvokableGlimmerComponentSignature` form +- the fully-expanded `InvokableComponentSignature` form + +[fully-working]: https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgUwB5mQYxgFQJ4YDyAZnAL5zFQQhwDkaG2AtDAcnQNwBQ3AJlgA2AQyjI4mCADsAzvACi4NgC44AVynAAjmvEy8IAEYRBPNhjiKwbQoYBWWeAF5EcANpW2AXQD8qmFC65Dzc5uIA4sgwhFDygjLIADy2dgA0cADS6QBiwoKChsKYANYAfHAuGSioMMhSfDJwxch4EKQpcD5wKW4ZXnCqufmFJSFhcACCUADmMtnQiQDK5S7NraSL1bX1jQBEU7O7cAA+cLtxyCB1MEenuwBCghAlMkcA9G9wAJKNMAAWwEawjgMmA0ykwhgajEPm4cE6ZwOry2dQaTRabTgmw+cAAIhBkL8AY1QeDIdDxH9hAA3cQAAyRdLgAmIwCkyD4sPh8K6izc+xmr36aG2aKQADlhFdOaoIPZHJw4AAFCCgmDAaR5PzqKTFKQQADuUjc-QoOKm4n+LTgTyk0yp9TgeTEwj4eC53J5rkl0tUkWisXiST5AsOXnSdB9HLo6U8eBSjlKipVao1EMEfqiMTiCSW-KRu3D9BTwHVmsEMfcXnKZDhnoG3qlHNUIYLXmTqtLabyqhN5Dr8NUEqbfFUcYT2A7qfLvdNdaHcCjo6xU67M6rwV4bwAVNu4OFgLSpE6pHgnflDRyQX9oP9hI7iNBaJjgaSIVCxOkBDI1NNRI1SzgGAIDgUtGmINR8mqMB7wEPggPYOBtzeUJEPkdBYMWMF3wpRIcBWRA6wuK4pBgVRzkES5riOEVUUaNZMRwBEcH5YjqP6VQpEg0w6yRVQkXmKA8KTOtHmeYoZHIsSXhomo6PRdY4BwAcEQQFTPTcaTilA48GNIFiHieGSvC8VQDK0oVNKM4phTknYdT1Q1jS8dT6y6JASzLdMzP5CzCys8S53resfMM8TLK09tXNrT0xyUeN5UnbgyBCHEAAFpkEUArigN5JHAaRrjgABGAA6AAGbg2VqKBiCKcRCEEPhFkwG8TCRQj4UfCBVDkKA2WmHha24HFdmENR-mgAbKCfSEjghaVmFEWZmGkQQ8Cq0jkFq+qsRvWAHT4JFCCkdbOsmQUFxU7reoCAaeHhWthpxWkoGMBJ0kMCabWQGlpogb7GCyzBAM87tBE2mq6swcQl2O06zzU+E+PO+ElyHVybpBO67Qe7kYv7Z7Ple97xAWjklsFVaEbgAAKOl0Jg+osLJD9kCZGQb0g+DDHEYF9QgMA4GkIDiQASkh7bofEbJuPhs6kYu2YMc9MH1xNPG0ZHFX6yxvr7pUgmUqIyiSLIuAuPyPGLLi6wEocJLhsYRx8CIYhEkZzDsPJMRkialq2sEJFSlKWmxdK4D5B0PJXeQEhEll-J5bwUOxZ4Z3sFj+PPeZ722aWfa73qZOQ7DiOICjtQY-YePE6DwUTvW1P0-QF2a-dnPmrz3C4YbhHS-DyPo8ELP3brkuw5bphcHbj2MNz1ncPHvum9T8vK+rt2E7lleU8nrdPjGib9umx8oBAObmWQOrIPgQxrOFhHJZ2mG9tvQ6LMbxHROsyTUavm+ghzZuCuDIGQwhpjIFuv1O0UVHrJQPnAEmqpxAsnGkAuA99xKPzOvTTuLMcJiA5lzJqmC+YWwgILR+otAQS2qlLXaddP400VmxUinFuJ4xRsOX0lh4oThgKuLyPYqyKmNvCG2-80G3x1qrTswiMzuD1jjaYcD8Z1mNk7VumdZ74O7r7RYhcP6-y-gPdew9R7b3yMwpu+8M4zy3noxevsmEmP7mvIeVcR6z1ceFUx+8RqH3GpNGB0wZrn0vsgU2RU1obXoS-cQhj36wTYdEFhJsqLsLgAACRwAAWQADIAGF7zUmEDIVJQ1EHIISCgaJpEcFnjwfPLuzj2bXgBqQ3mToKFUJFv8Whz9pZwDrqkr+51JHjkSoI3il1Gy8KmQ7QRyp5Hg1nJueEqTVC5MKSUqQZSKn1JmVo6elinGEODEYlJRzTEeIrhYnx3Exn9zsdohxccO4tIIT7JIoybnuLLp4zeHyrGCGebYtOiCIL5DPJAMAkFIQcnSNCs6jBYJXjfD8umDMvn6PaZzTpPNyECyFv08WQzdryFengAZdp7gQKiedbhKl0auGUaEjZ3I1beXcKWS4HCjDbTUeIywRztn5OKaU8plSf7hVkQA9B5tFZyOnDytwZTBC6F6gYYwggXKeiNgg7g9izm4raR7altLpj0sgYIMxQLvGOMtQCOlDK7UBPGEUmgkB2SkSREsAiAkFjLDcJGEcdAor8CEKIcQmARBgLgF6gqvqYABvOi6PgsSnRzKDUJENYbpQRqNXG8pjRGrNTYJRFE9kk0+uuH7ZqrVKH11mOURWkD4DdTDv-MQH5jwDJkKVZag7up4yerwEtCbxTIANIsSt4haI1u9YVUiBdknF13m2usHaZoQG7cquAvboT9uJEOwUpVR0aOSkAA + + +### Example + +Given a component with this template: ```hbs
@@ -82,7 +214,7 @@ import Component from '@glimmer/component'; export interface NoticeArgs { /** The kind of message displayed in this notice. Defaults to `info`. */ - kind: 'error' | 'warning' | 'info' | 'success'; + kind?: 'error' | 'warning' | 'info' | 'success'; } export default class Notice extends Component { @@ -99,14 +231,16 @@ export interface NoticeSignature { Element: HTMLDivElement; Args: { /** The kind of message displayed in this notice. Defaults to `info`. */ - kind: 'error' | 'warning' | 'info' | 'success'; + kind?: 'error' | 'warning' | 'info' | 'success'; }; /** * Any default block content will be displayed in the body of the notice. * This block receives a single `dismiss` parameter, which is an action * that can be used to dismiss the notice. */ - BlockParams: [dismiss: () => void]; + Blocks: { + default: [dismiss: () => void]; + }; } export default class Notice extends Component { @@ -114,30 +248,34 @@ export default class Notice extends Component { } ``` -By nesting the existing `Args` type under a top-level _signature_ type, we create a place to provide additional type information for other aspects of a component's behavior. One key element of this design is that eases future evolution of the signature, as including additional keys with new meaning won't be a breaking change. +By nesting the existing `Args` type under a top-level _signature_ type, we create a place to provide additional type information for other aspects of a component's behavior. This, along with the fully-expanded/desugared form, is the key element of this design which enables future evolution of the signature, as including additional keys with new meaning won't be a breaking change. + ### `Args` The `Args` signature member works exactly as the top-level `Args` type parameter does in `@glimmer/component@1.x` prior to this RFC. That is, it determines the type of `this.args` in the component backing class, as well as acting as an anchor for human-readable documentation for specific arguments. -A signature with no `Args` indicates that its component does not accept any arguments. +**A signature with no `Args` indicates that its component does not accept any arguments. The type of the `args` field on a Glimmer component class is an empty object.** -### `BlockParams` -The `BlockParams` member dictates what blocks a component accepts and specifies what parameters, if any, it provides to those blocks. When `BlockParams` is a simple tuple type, that indicates that the component only yields to the default block: +### `Blocks` + +The `Blocks` member dictates what blocks a component accepts and specifies what parameters, if any, it provides to those blocks. All blocks must named explicitly.[^blocks-sugar] When the component only yields to the default block, simply name the `default` block: ```ts -BlockParams: [name: string]; +Blocks: { + default: [name: string]; +} ``` ```hbs {{yield "Tomster"}} ``` -However, `BlockParams` may also be an object type indicating what parameters a component's named blocks provide: +When there are multiple blocks, each is named to indicate what parameters a component's named blocks provide: ```ts -BlockParams: { +Blocks: { header: []; body: [item: T; index: number] } @@ -151,9 +289,9 @@ BlockParams: { {{/each}} ``` -If a component also accepts both a default block _and_ other named blocks, it can specify the default block by name (`default: [...]`) in its `BlockParams` in exactly the same way a consumer of the component might use `<:default>` to pass a default block alongside named ones when invoking a component. +This means that if a component also accepts both a default block _and_ other named blocks, it can specify the default block by name (`default: [...]`) in its `Blocks` in exactly the same way a consumer of the component might use `<:default>` to pass a default block alongside named ones when invoking a component. -A signature with no `BlockParams` indicates that its component never yields to any blocks. +**A signature with no `Blocks` indicates that its component never yields to any blocks.** The [yieldable named blocks RFC] and recent versions of the [Component guides] discuss blocks in some depth, but since named blocks in particular are relatively new to the community, brief definitions based on those in [RFC 678] are included here for clarity. @@ -236,7 +374,7 @@ The [yieldable named blocks RFC] and recent versions of the [Component guides] d ``` -The `BlockParams` type maps the block names a component accepts to a tuple type representing the parameters those blocks will receive. +The `Blocks` type maps the block names a component accepts to a tuple type representing the parameters those blocks will receive. As a concrete example, the `BlogPost` component in the [Block Parameters section] of the Ember guides looks like this: @@ -264,7 +402,9 @@ The signature for this component might look like: ```ts export interface BlogPostSignature { Args: { post: Post }; - BlockParams: [postTitle: string, postAuthor: string, postBody: string]; + Blocks: { + default: [postTitle: string, postAuthor: string, postBody: string]; + }; } ``` @@ -292,7 +432,7 @@ export interface FancyTableSignature { /** The items to be displayed in the fancy table, each corresponding to one row. */ items: Array }; - BlockParams: { + Blocks: { /** Any header contents for the table, broken into cells. */ header: []; @@ -308,11 +448,14 @@ export default class FancyTable extends Component> { While the runtime design of named blocks currently only permits components to yield parameters out to them, community members have suggested possible ways of making blocks more akin to components themselves, potentially accepting `@args` or attributes, or even themselves accepting further nested blocks. Should such a design ever become reality, the shape of a signature might evolve to become more recursive. Today, however, there are many open questions about how such functionality would actually work for authors, and so we keep the proposed format here simple. +[^blocks-sugar]: We recognize that it might be desirable to have a shorthand for the very common case of having a single default block. However, none of the designs we have seen so far are satisfactory across the board in terms of teaching and mental model, so we are using this expanded form which *does* satisfy those constraints. Over time, we hope to come up with a nice bit of “sugar” that works there, but we are not *blocked* on finding that sugar. + + ### `Element` The `Element` member of the signature declares what type of DOM element(s), if any, this component may be treated as encapsulating. That is, setting a non-`null` type for this member declares that this component may have HTML attributes applied to it, and that type reflects the type of DOM `Element` object any modifiers applied to the component will receive when they execute. -A signature with no `Element` or with `Element: null` indicates that its component does not accept HTML attributes and modifiers at all. While applying attributes or modifiers to such a component wouldn't produce a runtime error, it still likely constitutes a mistake on the author's part, similar to [passing an unknown key in an object literal][ecp]. +**A signature with no `Element` or with `Element: null` indicates that its component does not accept HTML attributes and modifiers at all.** While applying attributes or modifiers to such a component wouldn't produce a runtime error, it still likely constitutes a mistake on the author's part, similar to [passing an unknown key in an object literal][ecp]. For example, [`{{animated-if}}`] would omit `Element` from its signature, as it emits no DOM content. Even if you invoked it with angle-bracket syntax, any attributes or modifiers you applied wouldn't go anywhere. @@ -351,6 +494,7 @@ Similarly, a component that may use `...attributes` on an `` element or may n In cases where the distinction between possible elements is key to the functionality of the component and can be statically known based on the arguments passed in, the component author may choose to capture this as part of the signature at the expense of additional type-level bookkeeping.
Gritty details + The particular shape/value of arguments is something that varies from instance to instance of the component, and the standard tool in TypeScript for handling these cases is to introduce a _type parameter_ on the type(s) in question. For the `{{#if @destination}}` example above, the implementation might look like this: @@ -361,7 +505,9 @@ interface MaybeLinkSignature { destination: Destination; target?: string; }; - BlockParams: []; + Blocks: { + default: [] + }; Element: Destination extends string ? HTMLAnchorElement : HTMLSpanElement; @@ -380,50 +526,34 @@ Note, still, that general-purpose modifiers like `{{on}}` or `{{did-insert}}` wo Finally, template analysis tools can provide escape hatches in the same vein as TypeScript's `@ts-ignore` and `@ts-expect-error` for these or any cases where consumers have information that library authors haven't encoded in the type system.
+ ## How we teach this ### Documentation -TypeScript is [not (currently) officially supported by Ember](https://github.com/emberjs/rfcs/pull/724), and as such none of the framework guides or API documentation mention the existing `Args` type parameter today. The upshot of this is that, while the `Args` type parameter is well known by the portion of the Ember community that works with TypeScript, there's no official documentation to be updated. +Ember is [in the midst](https://github.com/emberjs/rfcs/pull/724) of [adding official supported for TypeScript](https://github.com/emberjs/rfcs/pull/800), and as such none of the framework guides or API documentation mention the existing `Args` type parameter today. The upshot of this is that, while the `Args` type parameter is well known by the portion of the Ember community that works with TypeScript, there's no official documentation to be updated. The [`ember-cli-typescript` website], however, does have a section on working with components in TypeScript that deals almost exclusively with the `Args` parameter and `this.args` on the backing class. We should update and expand this documentation to cover the other concepts included in the `Signature` type. [`ember-cli-typescript` website]: https://docs.ember-cli-typescript.com/ember/components#understanding-args -### Naming - -The `Signature` concept itself has been [used in Glint] and broadly well received and understood, but the naming of each of the three member elements has received some discussion. While the names proposed above are what we currently believe to be best suited from both pedagogical and API-consistency perspectives, we're certainly interested in community feedback at this stage. +When we add sections documenting the use of TypeScript to the official guides, add type information to the API docs, and and so on, we will migrate that discussion from the `ember-cli-typescript` documentation into the main site documentation. -[used in Glint]: https://github.com/typed-ember/glint#component-signatures +Glint and its current documentation currently use earlier names and structures for the basic ideas in this signature, and will be updated to match this spec. -#### `Args` - -Early discussions have largely been on board with `Args` as it stands, though the non-abbreviated option `Arguments` has been suggested as an alternative. Since `Args` aligns better with `this.args` on the backing class (as well as the way in which people often colloquially discuss `@arg` values), we're continuing to propose `Args` here. - -#### `Element` - -Among early adopters of Glint there has been some confusion as to what the purpose of this key is. Generally "it's the concrete place your `...attributes` ultimately land after being passed down through components" has worked as an explainer, but the fact that an explainer is needed may indicate that `Element` isn't a clear name on its own. - -That said, even among those who were initially unclear what the purpose of `Element` was, no one has been able to come up with an alternative proposal. Attempts at more explicit formulations like `UltimateSplattributesTarget` don't quite roll off the tongue 🙂 - -#### `BlockParams` - -This key has easily been the largest topic of discussion among the three `Signature` members. Currently Glint uses `Yields` for this concept, but developers have given consistent feedback that that doesn't fit with `Args` and `Element` in their mental model. - -`Blocks` has been the most commonly-suggested alternative, but that doesn't quite align with the information being captured. Blocks are the chunks of content passed _in_ to a component by the author invoking it, while this signature member captures what parameters (if any) the component will pass _out_ to those blocks. - -For all three signature members proposed here, Glint will align its API with whatever terminology this RFC lands on. ### Migration -We can implement this change in `@glimmer/component` 1.x in a backwards compatible way, allowing for a deprecation period before moving exclusively to the signature approach in 2.0. Because capitalized arg names are currently illegal, any valid signature will not represent valid arg names, so the backing class can [accept both formats](https://tsplay.dev/Nr2rzN) and distinguish which was intended. +As noted in [**Detailed Design**](#detailed-design), we can implement this change in `@glimmer/component` 1.x in a backwards compatible way, allowing for a deprecation period before moving exclusively to the signature approach in 2.0. Because capitalized arg names are currently illegal, any valid signature will not represent valid arg names, so the backing class can [accept both formats](https://tsplay.dev/Nr2rzN) and distinguish which was intended. Ideally we'll be able to encourage authors to migrate their usage of `Args` in the same ways they're used to being nudged to move away from other deprecated APIs in the Ember ecosystem. + #### Codemods A simple codemod to turn `Component` into `Component<{ Args: MyArgs }>` would be straightforward, but it may also be feasible to migrate more completely by inferring information like block names and where `...splattributes` are used from colocated templates. The richness we pursue here will likely depend on the appetite of the community to explore what's possible. + #### Deprecation Warnings/Linting While type-only deprecations aren't something the Ember ecosystem has dealt with much previously, it's a muscle we may want to begin building as [official TypeScript support] is under consideration. @@ -433,12 +563,14 @@ The `@typescript-eslint` suite of packages supports writing [type-aware rules] i [official TypeScript support]: https://github.com/emberjs/rfcs/pull/724 [type-aware rules]: https://github.com/typescript-eslint/typescript-eslint#can-we-write-rules-which-leverage-type-information + ## Drawbacks As with any change that deprecates supported behavior, there's an inherent cost associated with migrating the user base over to new patterns. One goal of this change, however, is to ease such migrations in the future: as the templating system evolves and new information becomes relevant to capture and document, the `Signature` type provides a place for that information to live without disrupting existing code. The other potential drawback to this approach is that it introduces TypeScript type information that has no visible effect on the component using out-of-the-box tooling. Without a template-aware system like Glint or `els-addon-typed-templates` for validating components, nothing enforces that the signature declared is actually accurate. See the "Only `Args`" section under Alternatives below for further discussion of this point. + ## Alternatives ### Additional Positional Type Parameters @@ -446,7 +578,7 @@ The other potential drawback to this approach is that it introduces TypeScript t Rather than wrapping the information we're interested in capturing in a `Signature` type, we could instead introduce further type parameters to the `Component` base class: ```ts -class Component { +class Component { // ... } ``` @@ -456,6 +588,32 @@ This has the advantage of not requiring current users of the `Args` parameter to ### Only `Args` -One of the drawbacks mentioned above is that the `Element` and `BlockParams` signature members described in this RFC are functionally inert in a vanilla TypeScript project. This leaves them with about the same status as comments: potentially helpful when left by a well-meaning author, but without any checks to ensure that they're accurate and that they stay up-to-date as the implementation changes. +One of the drawbacks mentioned above is that the `Element` and `Blocks` signature members described in this RFC are functionally inert in a vanilla TypeScript project. This leaves them with about the same status as comments: potentially helpful when left by a well-meaning author, but without any checks to ensure that they're accurate and that they stay up-to-date as the implementation changes. An alternative would be to still introduce the `Signature` type in `@glimmer/component` but _only_ formalize the `Args` member, leaving other tooling to define the semantics of any additional signature members they might be interested in. While this would simplify the overall proposal somewhat, what a component `{{yield}}`s and what it does with its `...attributes` are core enough to a component's public interface that we believe they should be considered first-class rather than having individual tools reinvent them, potentially in mutually-incompatible ways. + + +### Naming + +The `Signature` concept itself has been [used in Glint] and broadly well received and understood, but the naming of each of the three member elements has received some discussion. While the names proposed above are what we currently believe to be best suited from both pedagogical and API-consistency perspectives, there are alternatives we could choose (and have explored). + +[used in Glint]: https://github.com/typed-ember/glint#component-signatures + + +#### `Args` + +Early discussions have largely been on board with `Args` as it stands, though the non-abbreviated option `Arguments` has been suggested as an alternative. Since `Args` aligns better with `this.args` on the backing class (as well as the way in which people often colloquially discuss `@arg` values), we're continuing to propose `Args` here. + + +#### `Element` + +Among early adopters of Glint there has been some confusion as to what the purpose of this key is. Generally "it's the concrete place your `...attributes` ultimately land after being passed down through components" has worked as an explainer, but the fact that an explainer is needed may indicate that `Element` isn't a clear name on its own. + +That said, even among those who were initially unclear what the purpose of `Element` was, no one has been able to come up with an alternative proposal. Attempts at more explicit formulations like `UltimateSplattributesTarget` don't quite roll off the tongue 🙂 + + +#### `Blocks` + +This key has easily been the largest topic of discussion among the three `Signature` members. Currently Glint uses `Yields` for this concept, but developers have given consistent feedback that that doesn't fit with `Args` and `Element` in their mental model. Moreover, the Framework team noted that it also doesn’t match with hoped-for iterations on the mental model after Polaris, where the notion of “yielding” (largely a holdover from Ember’s roots with many of its designers coming from the Ruby world) is less prominent or removed entirely in favor of new language/concepts. + +An earlier draft also used `BlockParams`, but that was not readily extensible to capture future information about blocks. Since Blocks are the chunks of content passed _in_ to a component by the author invoking it, while this signature member captures what parameters (if any) the component will pass _out_ to those blocks, we also did not want to support a shorthand like `Blocks: []`, which could very easily be misread as “there are no blocks” instead of “there is a default block which yields no params”. From ad20ee2c9d1b0b2f0c2bc89aa71f49d458293825 Mon Sep 17 00:00:00 2001 From: Chris Krycho Date: Wed, 9 Mar 2022 15:51:43 -0700 Subject: [PATCH 08/13] Update text/0748-glimmer-component-signature.md --- text/0748-glimmer-component-signature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index d370fe4507..2f72373c7e 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -13,7 +13,7 @@ RFC PR: https://github.com/emberjs/rfcs/pull/748 In TypeScript, the `@glimmer/component` base class currently has a single `Args` type parameter. This parameter declares the names and types of the arguments the component expects to receive. -This RFC proposes a change to that type parameter to become `Signature`, capturing more complete information about how components can be used in a template, including their expected **arguments**, the **block parameters** they provide, and what type of **element(s)** they apply any received attributes and modifiers to. +This RFC proposes a change to that type parameter to become `Signature`, capturing more complete information about how components can be used in a template, including their expected **arguments**, the **blocks** they provide, and what type of **element(s)** they apply any received attributes and modifiers to. This RFC is based in large part on prior work by [@gossi] and on learnings from [Glint]. From 9e5479e9b9ba25386e5290788e3cfe89666aa728 Mon Sep 17 00:00:00 2001 From: Chris Krycho Date: Wed, 9 Mar 2022 18:21:51 -0700 Subject: [PATCH 09/13] Fix GlimmerComponentSignature Blocks --- text/0748-glimmer-component-signature.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 2f72373c7e..012d941c80 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -150,11 +150,9 @@ interface GlimmerComponentSignature { [argName: string]: unknown; }; Blocks?: { - [blockName: string]: { - Params?: - | unknown[] - | { Positional: unknown[] } - } + [blockName: string]: + | unknown[] + | { Positional: unknown[] } } Element?: Element | null; } From 7b66083ed4acaa78b6d0908a3c302c40a9e6a448 Mon Sep 17 00:00:00 2001 From: Chris Krycho Date: Wed, 9 Mar 2022 18:23:17 -0700 Subject: [PATCH 10/13] Blocks in GlimmerComponentSignature only take short form --- text/0748-glimmer-component-signature.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 012d941c80..2804d8c4ac 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -150,9 +150,7 @@ interface GlimmerComponentSignature { [argName: string]: unknown; }; Blocks?: { - [blockName: string]: - | unknown[] - | { Positional: unknown[] } + [blockName: string]: unknown[] } Element?: Element | null; } From 14065195cf10781f601e973a4e2225f5a4787ccc Mon Sep 17 00:00:00 2001 From: Chris Krycho Date: Wed, 9 Mar 2022 18:32:35 -0700 Subject: [PATCH 11/13] Include constructor in Component type --- text/0748-glimmer-component-signature.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 2804d8c4ac..07be5b06c0 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -170,16 +170,20 @@ With these signature types defined, we can update the type for the Glimmer `Comp Previously: ```ts -class Component { +declare class Component { readonly args: Args; + + constructor(owner: Owner, args: Args); } ``` Updated: ```ts -class Component { +declare class Component { readonly args: ComponentArgs; + + constructor(owner: Owner, args: ComponentArgs); } ``` From 27a933c17c4fd96abe5e8a2f5c9f155d788f8915 Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Thu, 10 Mar 2022 09:35:58 +0100 Subject: [PATCH 12/13] Update text/0748-glimmer-component-signature.md --- text/0748-glimmer-component-signature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 07be5b06c0..7e6b3cea23 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -13,7 +13,7 @@ RFC PR: https://github.com/emberjs/rfcs/pull/748 In TypeScript, the `@glimmer/component` base class currently has a single `Args` type parameter. This parameter declares the names and types of the arguments the component expects to receive. -This RFC proposes a change to that type parameter to become `Signature`, capturing more complete information about how components can be used in a template, including their expected **arguments**, the **blocks** they provide, and what type of **element(s)** they apply any received attributes and modifiers to. +This RFC proposes a change to that type parameter to become `Signature`, capturing more complete information about how components can be used in a template, including their expected **arguments**, the **blocks** they accept, and what type of **element(s)** they apply any received attributes and modifiers to. This RFC is based in large part on prior work by [@gossi] and on learnings from [Glint]. From c81d75886111d953a2cf7233cafb52c33fda3f68 Mon Sep 17 00:00:00 2001 From: Chris Krycho Date: Tue, 15 Mar 2022 17:14:30 -0600 Subject: [PATCH 13/13] Incorporate last round of Spec team discussion Expand the full form to include `Params` as an intermediate term in the signature, so that other things can slot in as peers to it in the future if or when that makes sense, and so that it makes sense what exactly is being named as a `Positional` field there. --- text/0748-glimmer-component-signature.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/text/0748-glimmer-component-signature.md b/text/0748-glimmer-component-signature.md index 7e6b3cea23..1068fd6407 100644 --- a/text/0748-glimmer-component-signature.md +++ b/text/0748-glimmer-component-signature.md @@ -113,7 +113,9 @@ Two points to notice about the signature: // arguments, and could be reused for helpers, modifiers, etc. interface InvokableSignature { Args?: { - Named?: Record; + Named?: { + [argName: string]: unknown; + }; Positional?: unknown[]; }; } @@ -123,14 +125,18 @@ interface InvokableComponentSignature extends InvokableSignature { Element?: Element | null; Blocks?: { [blockName: string]: { - Positional?: unknown[]; + Params?: { + Positional?: unknown[]; + }; } } } interface InvokableGlimmerComponentSignature extends InvokableComponentSignature { Args?: { - Named?: Record; + Named?: { + [argName: string]: unknown; + }; // empty tuple here means it does not allow *any* positional params Positional?: []; } @@ -150,8 +156,8 @@ interface GlimmerComponentSignature { [argName: string]: unknown; }; Blocks?: { - [blockName: string]: unknown[] - } + [blockName: string]: unknown[]; + }; Element?: Element | null; } ```