Description
Summary
Currently, component generic type inference is based exclusively on parameters passed to that component, ignoring any other context such as ancestor components. This imposes problematic limitations in more sophisticated scenarios, particularly for component vendors trying to create a really smooth consumption experience.
See customer report in #7268, but note this has also been requested independently by component vendors.
Motivation and goals
The classic example is a generic <Grid>
component containing generic <Column>
children. As of today you have to do something like this:
<Grid Items="@people">
<Column TItem="Person" Name="Full name">@context.FirstName @context.LastName</Column>
<Column TItem="Person" Name="E-mail address">@context.Email</Column>
</Grid>
...when what you actually want to do is this:
<Grid Items="@people">
<Column Name="Full name">@context.FirstName @context.LastName</Column>
<Column Name="E-mail address">@context.Email</Column>
</Grid>
Notice how the consumer has to re-specify TItem
on each column, because it can't be inferred from the enclosing <Grid>
(even though <Grid>
itself can infer based on its own Items
parameter).
In scope
- Inferring a generic type from an ancestor in the same
.razor
file- ... when a same-named generic type is specified explicitly on the ancestor (e.g.,
<Grid TItem=Person>
) - ... when a same-named generic type is inferred on the ancestor (e.g.,
<Grid Items=@someEnumerableOfPerson>
)
- ... when a same-named generic type is specified explicitly on the ancestor (e.g.,
- Sensible overriding rules
- We still prefer to use explicitly-provided generic type parameters (e.g.,
<Column TItem=Person>
) - that overrules all inference - Failing that, we still prefer to infer based on a regular
[Parameter]
- that overrules inference based on ancestors - Only failing both of those do we go looking for same-named ancestor generic type parameters
- We still prefer to use explicitly-provided generic type parameters (e.g.,
- Continuing to provide a good compile error if one or more generic types can't be inferred
Open questions:
- Instead of only matching based on generic type names, should we have a way to specify in a strongly-typed way which other component(s) are valid suppliers of values for each generic type? This probably requires new syntax, e.g., a directive like
@typeparam TItem from Grid
. I'm not keen on inventing new syntax like that. - Or should we wait until implementing some kind of "Restrict component hierarchy" feature, and then say we're only going to infer based on the specific ancestors that are declared as required? I'm not convinced this is a good idea, because "Restrict component hierarchy" will probably only support immediate-child/parent relationships, otherwise it runs into trouble when people want to split things over multiple
.razor
files, and in that case it's not really flexible enough to handle the requirements for generic type inference. For generic type inference, we want to support arbitrary depth ancestors within the same.razor
file.
Out of scope
- Partial generic type inference. Due to C# compiler limitations, if you specify any generic type values explicitly, you must specify all of them explicitly. This is an existing limitation in the Razor compiler too, and the work here isn't going to change that.
- Matching against an ancestor in a different
.razor
file. We can't know which other.razor
files are going to contain a reference to your component, hence we don't know what the ancestors will be. Even if we did, there might be multiple ones that would "supply" different generic types. Altogether it's meaningless to imagine we're matching against "ancestors" in entirely separate files.- Note that people can still do generic type inference across files, but only the existing form of generic type inference that requires your component to receive a parameter involving that generic type.
- Anything cleverer than matching based on generic type parameter name. For example, even if the closest candidate match is incompatible due to generic type constraints, we're not going to know about that and pick a different candidate. Likewise, if the candidate you want to match has a different name, we can't know that's your intention (even if there's only one such candidate).
Risks / unknowns
It's taking a fairly magic thing and making it more magic still. It will become fairly hard to explain the exact rules around type inference.
It makes the declared generic type parameter name more important. It's no longer just named for explanatory purposes, but also for uniqueness purposes. Some existing components might have generic type parameter names that aren't unique enough (e.g., just T
) and so the inference-based-on-ancestors mechanism might match a different ancestor than you intended. This isn't massively bad because you'd know about it at compile time, plus in most cases, developers can change their generic type names to make them more unique when required. We should check that our own generic components have good type generic type names.
If we implement an "Extract Component"-type refactoring in the future, it will have to account for this. That is, it will have to work out which generic types were being inferred from an ancestor, and convert those into @typeparam
declarations on the extracted component.
Examples
See the <Grid>
example above as the primary scenario.
Other scenarios are anything where you have multiple child components that all take a RenderFragment<T> ChildContent
parameter, each of which does something with the same data source. Example: a <Chart>
component containing multiple <Series>
.
Even if literally the only scenario was "grids", I think that would still be important enough to warrant this work.
Also bear in mind cases where the inference needs to flow through multiple levels in the component hierarchy. You don't always infer based on the immediate parent. For example,
<Grid Items="@people">
<CascadingValue Value="@someUnrelatedThing">
<Column Name="Full name">@context.FirstName @context.LastName</Column>
<Column Name="E-mail address">@context.Email</Column>
</CascadingValue>
<Column Name="Actions"><button ... /></Column>
</Grid>