-
Notifications
You must be signed in to change notification settings - Fork 10.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Infer component generic types from ancestor components #29349
Comments
Based on the out-of-scope "partial inference" comment, would it still be possible to support implicit inference from both immediate parameter and ancestor types? In other words as long as there are no explicit type parameters provided, all other inference types would be allowed? |
Just thinking out loud... instead of inferring the generic type from ancestor components, would it be possible to infer it from Cascading Parameters? This would avoid the unique name dependency, and potentially could even allow inference across files. It would also provide a form of a more strongly-typed generic inference. |
I know we've already established that this feature is desirable, so I'm moving on to some candidate implementation suggestions. Implementation proposalsBefore we start, first understand how the existing generic type inference works. As of 5.0, Razor's generic type inference works by converting "the code that emits component frames" into "a method call that emits those component frames". As a simplified example, without generic inference we might have this, where // <Column TItem=Person ColumnName="@someColumnName" Items=@people />
void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<Column<Person>>(0);
builder.AddAttribute(1, "ColumnName", someColumnName);
builder.AddAttribute(2, "Items", people);
builder.CloseComponent();
} Whereas with generic type inference we have this: // <Column ColumnName="@someColumnName" Items=@people />
void BuildRenderTree(RenderTreeBuilder builder)
{
EmitColumnComponent(builder, arg0: someColumnName, arg1: people);
}
// This is generated by the Razor compiler
static void EmitColumnComponent<TItem>(RenderTreeBuilder builder, string arg0, IEnumerable<TItem> arg1)
{
builder.OpenComponent<Column<TItem>>(0);
builder.AddAttribute(1, "ColumnName", arg0);
builder.AddAttribute(2, "Items", arg1);
builder.CloseComponent();
} The C# compiler does the fancy work of inferring the Notice how It's actually slightly more complicated still, but that's the core idea (by @rynowak originally, mentioning him in case he wants to come and share any further wisdom!) and is all we need to know to understand the following. 1. Have the C# compiler get the generic type name from lexical scopeSpoiler: this is not my preferred option - see later What if, instead of Then to make this safe so you can still reference variables that are only in scope at the call site, we could change the whole thing to be a local function: // <Column ColumnName="@someColumnName" Items=@people />
void BuildRenderTree(RenderTreeBuilder builder)
{
EmitColumnComponent(builder, arg1: people);
// This is generated by the Razor compiler
static void EmitColumnComponent<TItem>(RenderTreeBuilder builder, IEnumerable<TItem> arg1)
{
builder.OpenComponent<Column<TItem>>(0);
builder.AddAttribute(1, "ColumnName", someColumnName);
builder.AddAttribute(2, "Items", arg1);
builder.CloseComponent();
}
} Now, if we have multiple nested levels of type inference, they will get compiled as multiple nested levels of local functions, hence they are within each other's scopes, e.g.: void BuildRenderTree(...)
{
...
EmitGridComponent<TItem>(...)
{
...
EmitColumnComponent(...)
{
...
}
}
} So at this point, all the Razor compiler has to do is not put any generic type declarations on the nested 2. Extend the type inference method to take extra synthetic parameters used for inference onlyAs an alternative, instead of changing where the type inference methods go, we could just add some more parameters to them. As of 5.0, they take one parameter for each For example, given this markup (where both <Grid Items="@people">
<Column IsCoolColumn="true" />
</Grid> ... we generated the following: void BuildRenderTree(RenderTreeBuilder builder)
{
EmitGridComponent(builder, arg0: people, arg1: (RenderFragment)(builder =>
{
EmitColumnComponent(builder, syntheticArg0: people, arg0: true);
});
}
static void EmitGridComponent<TItem>(RenderTreeBuilder builder, IEnumerable<TItem> arg0, RenderFragment arg1)
{
builder.OpenComponent<Grid<TItem>>(0);
builder.AddAttribute(1, "Items", arg0);
builder.AddAttribute(2, "ChildContent", arg1);
builder.CloseComponent();
}
static void EmitColumnComponent<TItem>(RenderTreeBuilder builder, IEnumerable<TItem> syntheticArg0, bool arg0)
{
builder.OpenComponent<Column<TItem>>(0);
builder.AddAttribute(1, "IsCoolColumn", arg0);
builder.CloseComponent();
} That is, when rendering We discover Even though 3. Use some extension of
|
Just to harp on the cascading param idea a bit more... perhaps I'm missing a subtle point, but while I agree that populating a cascading parameter happens at runtime, isn't the type specification fully declared and known at compile time? You have to define a property on a component class and annotate it to declare a cascading parameter, so it's type should be fully resolvable at compile time. For example: public class MyComponent<TItem> : ComponentBase
{
[CascadingParameter]
public SomeType<TItem> SomeValue { get; set; }
} It wouldn't matter if Though, I'm sure I'm missing some subtle point here... |
@ebekker Sorry but I don't see how that fits in with how generic type inference works. You would need to provide a code sample showing exactly what the compiler would generate, for example in the case I was using above ( |
If you pass the synthetic arguments before the rest of the values to the emit-function I guess that something like |
@Liander As far as I can tell, yes it would be able to infer the type in this case. However I don't see any reason why it matters what order we put the arguments on the emit function. I think it would work exactly the same if the synthetic arguments go last. |
It matters for the intellisens, at least if it works like ordinary factory methods. |
It doesn't seem to make a difference when I tried it. If you could post a Gist showing a minimal example of a case where this matters I'd be interested. |
I started to look at the generated code of a sample supporting the cascading parameters and I see the errors of my way -- the surrounding page doesn't have any way to infer the type being passed from parent to child via a cascading parameter between the two -- I see your point now, thanks for humoring me! I was originally only thinking of the developer/user ergonomics but I can see the implementation is not feasible. |
@SteveSandersonMS I am confused. A gist will not show intellisens. I referring to the general c# behavior when having: |
@Liander Ah yes, I see what you mean! Thanks for clarifying. I thought you were talking about the order of the parameters declared on the emit function, but now I see you mean the order of the arguments on the invocation of the emit function (which can be a different order since we can use named parameters at the call site). I'll update the example code above to show the synthetic args going first in both cases to make this clearer and simpler. |
@SteveSandersonMS The goal of this is ultimately to allow for strongly typed RenderFragments.
Nobody wants to allow any old content in their section. We want to allow only the specified types. This would solve the inferred type requirement explicitly rather than via complex same-name rules. |
@mrpmorris I appreciate the appeal of that, but it sounds like a completely different kind of feature proposal. It sounds like the “restrict view hierarchy” suggestion from before, and I’m not sure how it would lead to any type inference based on how the C# compiler works. Rather than risking confusing people by discussing it in this issue, would you be able to file a separate issue describing your suggestion in more detail? Please include:
If there’s a good and practical way to do this we’d definitely be interested, but let’s do separate feature designs in separate issues. Thanks! |
@SteveSandersonMS I very strongly suspect (99.9%) that this is what the original report by @ivanchev is trying to achieve. If I open a new ticket it is basically going to be the same as #7268 which you have closed in favour of this one. I really don't think people want magic(ish) propagation of generic type parameters, what they actually want is strongly typed child components. The grid columns in a grid in the original ticket is a perfect example. The original poster doesn't want the consumer to be able to do something like this
and then have to only render components - they want to be able to prevent anything other than Column in there, and are compromising by asking for cascaded generic type parameters because we are more likely to get that than to get exactly what we want. Please correct me if I am wrong @ivanchev |
Please file a separate issue with the details requested. |
Done, thanks! |
@mrpmorris - these are two separate issues indeed. What you are referring to is to restrict the children of a certain RenderFragment Parameter(Columns), or component. While this is also useful, it's not the feature I requested. What I requested is exactly what is being proposed here by @SteveSandersonMS - a way to infer the type of child components. This will allow strongly typed child fragments, without specifying a type on each column. |
Thanks @ivanchev for clarifying. Since this proposal is not about restricting the type hierarchy, it allows for certain scenarios that are more general than would be allowed if we did restrict the type hierarchy. For example, having a layer of |
Also, yesterday @javiercn and I discussed these proposals at length and came up with a very simple improvement to the name-based matching to avoid most cases of accidental matches. See the new section about Matching rules above. |
Maybe it is not an intentional detail, but I do not think you can have the Emit-functions in proposal 2 as static because you should be able to access component instance members, correct? (That said, I actually do want to have the option of generating them static, to control what instance state to access and have change-detection of that explicit dependency if any exist, but that is another story...) Oh.. looks like it is only the BuildRenderTree that nests the Emit-functions... I must have recalled wrong... Ignore my note. |
They can be static, and in fact can be in an entirely different class, because they don't directly access anything from scope or other component members. They receive all the parameters as arguments (for example, child content is a |
@SteveSandersonMS this actually opens up a whole set of possibilities and I wonder if it syncs up a bit with the concept of a child component knowing as much as possible about its parent. We're having to do some tricks now to build out the component hierarchy. I wonder if having the TParent type param along with a corresponding TParent Parent parameter available for all components would solve this? We're effectively doing this now but only by subclassing ComponentBase and monkeying more than we'd like. |
I have been summoned... Option 2 seems pretty well thought through. This isn't that similar to how I'd imagined we'd solve this when I was on the team but seeing it all laid out with an example makes it really clear. I'm pleased that this solution avoids evaluation-order issues, which was one of my primary concerns implementing both this and the original type inference feature. For those that are driving by and don't get what I mean, it's pretty important that the generated code evaluates the component parameters in the order that you pass them from left to right (since that's what C# does). <Grid Items="@GetPeople()" AnotherThing="@DoSomething()">
<Column IsCoolColumn="true" />
</Grid> If mutate state in Synthesizing a 'thunk' and calling it avoids that problem since you're now working with a variable that already holds a value rather than copy-pasting code. The temptation for folks that are new to Razor and compilers in general is to assume that we can just copy-paste your code into a new context, and examples like the above demonstrate why you can't. The other path that I had in mind for this issue was related to discovery and nested classes. I'm a nested-class-a-holic, if you look at any codebase I've worked on seriously you will seem them everywhere! I wanted the world to share my joy. The insight is that a container like Nested classes can solve both of these problems at once - but only work really well if you always want to solve them both together. For instance if The one part of this that I don't love is Anyways much ❤️ to the community and team. I'm off on a new adventure now, and I'm really have to see the community so engaged and the team cranking out awesome work. |
Done in #29767 |
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:...when what you actually want to do is this:
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 ownItems
parameter).In scope
.razor
file<Grid TItem=Person>
)<Grid Items=@someEnumerableOfPerson>
)<Column TItem=Person>
) - that overrules all inference[Parameter]
- that overrules inference based on ancestorsOpen questions:
@typeparam TItem from Grid
. I'm not keen on inventing new syntax like that..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
.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.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,
The text was updated successfully, but these errors were encountered: