dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.44k stars 10.03k forks source link

Modify `ChangeDetection.IsKnownImmutableType` to support custom immutable types w/o perf impact #57776

Closed alexyakunin closed 1 month ago

alexyakunin commented 2 months ago

Summary

The last line in https://github.com/dotnet/aspnetcore/blob/main/src/Components/Components/src/ChangeDetection.cs#L45 checks for implementation of a very specific IEventCallback interface. If it's implemented, the type is treated as immutable.

Let's make IEventCallback a descendant of tagging IBlazorImmutable (the name is questionable, maybe we need IImmutable in .NET?) and use it instead in IsKnownImmutableType code.

Motivation and goals

There are cases where you'd rather detect parameter change with nearly zero false positives vs dealing with more false positives, which typically trigger re-renders or other logic that depends on component parameters. So it's good to have at least one easy option allowing you to declare a parameter type that relies on custom equality.

In scope / out of scope

Not sure what to write here: assuming we're talking about an interface specific to Blazor only (~ IBlazorImmutable), the proposed change is quite isolated.

Risks / unknowns

I don't see any risks except the scenario when developers implement IBlazorImmutable w/o weighting the cost of equality check for this type.

Examples

public record UserId : IBlazorImmutable { ... }
public record Avatar : IBlazorImmutable { ... }
public SmallSet<T> : IBlazorImmutable { ... }

Detailed design

// New interface
public interface IBlazorImmutable;

// Change:
public interface IEventCallback: IBlazorImmutable { ... }

// Change on L45 in ChangeDetection.cs:
// from:
|| typeof(IEventCallback).IsAssignableFrom(type);
// to:
|| typeof(IBlazorImmutable).IsAssignableFrom(type);
javiercn commented 2 months ago

@alexyakunin thanks for contacting us.

This algorithm is conservative and not extensible on purpose as allowing extensibility will result in situations where incorrect implementations cause the algorithm to not detect changes and not trigger additional renders when there are in fact changes, which will result in really hard to debug issues.

You can already control this behavior in your own component by overriding SetParametersAsync and avoiding a render if you deem the parameters haven't changed, but the default algorithm is conservative and flavors correctness over "efficiency". There's also another trade-off which is that the more things we check, the slower it becomes for every component to render, even if it uses existing parameter types.

We might in the future extend support for some types where the compiler provides the equality implementations (like records) but that's not currently a priority.

alexyakunin commented 2 months ago

@javiercn sorry, but I can't agree with you:

  1. Blaming yourself in allowing others to make mistakes is kinda bad excuse for making something closed rather than open.
  2. What's available now is, honestly, a bad option, coz it makes people build way more hacky solutions for problems they still have to tackle.

I certainly know about SetParametersAsync - https://github.com/ActualLab/Fusion/blob/master/src/ActualLab.Fusion.Blazor/Components/FusionComponentBase.cs#L23 ; feel free to check out how deep is the rabbit hole we dug w/ customizable parameter comparison. And all of this is necessary mainly to make sure the state of certain components is recomputed only when it makes sense - without too many false positives. And it's easy to notice there is way more code than it should be - thanks to, as you've said, "the algorithm that is not extensible on purpose".

Not sure if it's clear, but the fact you make it closed like this doesn't solve any problem - it just creates a bigger one. Overriding SetParametersAsync like we already do is one of such ways. But notice that nothing prevents us from "implementing" fake IEventCallback as well to make certain parameter types compared via regular equality. And as soon as this "solution" becomes a well-known one, the whole idea of closing the API like this becomes way more arguable than it seems now.

CC @SteveSandersonMS ?

javiercn commented 2 months ago

@alexyakunin thanks for the additional details.

A few things:

We might disagree on goals here:

In this case, we favor correctness over potential performance gains because we believe is much worse to run into a situation where you have a hard to debug issue than the cost of potentially triggering a few additional renders.

If you have components where rendering is expensive you can control when they render via ShouldRender or overriding SetParametersAsync. That's the way we recommend people handle this situation, as opposed to interfering with the diffing algorithm that has a much broader impact.

alexyakunin commented 2 months ago

@javiercn lol, I didn't notice IEventCallback is internal - i.e. yeah, it's hard to abuse it that way.

If we wanted to support custom equality, we would probably call Equals rather than our own interface.

IMO it's totally reasonable to make sure the default equality isn't used for parameter comparison:

So IMO what's already done is absolutely correct. And my suggestion doesn't break any benefits of existing soution.

In other words, I really see no cons.

alexyakunin commented 2 months ago
  • I've looked at the implementation you provided. It's unclear to me why you are comparing the properties instead of capturing the values from the ParameterView into a dictionary and comparing them against the new ones.

All depends on the number of such parameters you actually have: if it's about just a few components & params, of course you can manually copy the old value for each param & compare it with the new one in SetParametersAsync to decide whether the change had happened.

But what if it's about nearly every parameter in ComputedStateComponent<T> descendant? There can be hundreds such descendants, each with a few params they use in ComputeState(...). So when these params change, the state has to be recomputed, which typically triggers re-render.

You can certainly try to manually copy & compare all of such params, but this is definitely more error prone than using a solution which addresses the root cause & requires no manual handling of each specific case.

alexyakunin commented 2 months ago

We might disagree on goals here:

  • Triggering an extra render might introduce a performance penalty when the values have indeed not changed.
  • Not triggering a render will translate in an incorrect behavior of the app, that is also, really hard to debug.

Yes, I fully understand this. Just want to point out that suggested change doesn't break these assumptions.

If you have components where rendering is expensive you can control when they render via ShouldRender or overriding SetParametersAsync. That's the way we recommend people handle this situation, as opposed to interfering with the diffing algorithm that has a much broader impact.

That's exactly what we do. You may notice there is e.g. https://github.com/ActualLab/Fusion/blob/master/src/ActualLab.Fusion.Blazor/Components/ComputedRenderStateComponent.cs , which cancels renders unless the state it computes gets changed. It's not super straightforward from the code, but ultimately, it assumes that component's State (it's what ComputedStateComponent<T> computes/updates when State dependencies change) is the only data source for its render logic. So unless it changes, it doesn't have to re-render anything. And it uses State.Snapshot to track the changes.

Long story short, we know how it works pretty well.

alexyakunin commented 2 months ago

I also want to highlight that what I suggested is definitely sub-optimal:

And IMO this is a totally valid reason to reject this proposal as "not good enough". But let's come up with something that's good enough in this case. I hope it's clear that having an ability to override "blazor equality" is important, and personally I would be fine with any better option (e.g. making IChangeDetection DI-injectable, etc.).

I also think that having ~ public interface IChangeDetection + its default implementation is better than what we have right now (i.e. internal ChangeDetection type). I bet that maybe 80% of Blazor devs simply don't know how it works, and that's solely because it's buried deeply in Blazor internals. But if you want to eliminate excessive renders, you absolutely need to know how it works.

And that's where your "we need reliable re-renders on changes, so let's make Blazor equality 'stupid' and close it" starts to hurt anyone who crafts something bigger than a toy app:

javiercn commented 1 month ago

@alexyakunin thanks for the additional details.

I think we disagree at which level this should happen. For us:

Some more details:

Rendering is a very sensitive area of the codebase and a hot path, having other code running there creates all sort of opportunities to issues, from issues like "Why is my component not re-rendering" to "Why is my app suddenly slow" that can happen in very non-obvious ways.

The current approach offers predictability and stability both in terms of performance and when the component receives parameters. Any decision on when the component renders need to be performed at the component level itself, not on a central place that can have side-effects on the way other components receive parameters.

If we were to do something in this area, we would simplify the process of implementing this on a per-component level, but we wouldn't change this at the diffing level.

To follow with the example, you provided and as an alternative approach, source generation could be used to generate a method to perform the comparisons. Note that the implementation can be potentially simpler than the current one you have. You'd need to compare only parameters that aren't supported out of the box, not all the parameters, because if all supported parameters are equal, the component wouldn't receive a new set.

alexyakunin commented 1 month ago

Based on what I just read, my original suggestion w/ IBlazorImmutable fully fits the bill:

In other words, I don't understand your argument. Its applicable for another option I mentioned (extracting IChangeDetection), but not for the original one.

And... I'm not sure if you noticed, but I very explicitly saying that you advocate nearly this policy:

Developers: 
- Hey, why you gave us a hand saw while we clearly need a chainsaw?
Microsoft:
- Sorry, I can't give you a chainsaw, because you can cut your fingers. That doesn't mean I think you're dumb, of course.
Developers:
- But we need to cut through the forest! And it's our fingers, not yours - let us decide whether to risk them or not, ok?
Microsoft:
- Sorry, I can't see the forest in the toy apps and demos I've built for you.
Developers:
- Well, your 10K LOC demos aren't our 1M LOC codebases. That's why you don't see a plethora of other problems too.
Microsoft:
- I still insist my decision is correct. Also notice that I let you use a knife in addition to hand saw.
...

And speaking of source generators:

source generation could be used to generate a method to perform the comparisons

Even if we put aside all the pain and suffering you need to go through to implement a single one (believe me, I know what I'm talking about), I would be delighted to see A SINGLE source generator producing the source based on .razor / Blazor component source.

AFAIK there is a plethora of problems with this:

javiercn commented 1 month ago

@alexyakunin thanks for the additional details.

Based on what I just read, my original suggestion w/ IBlazorImmutable fully fits the bill:

I don't think this is the case for two reasons:

As for the source generators bit, I just wanted to point out as a potential future alternative. If we were to bake something like that in, we would integrate it directly within the Razor Compiler or solve the points that you made.

Even with the issues SG have today when dealing with Razor code, it's possible to solve that by moving the property definitions to a Code Behind file. I'm noy saying that it's ideal, just that it's possible.

As for the other comment, we don't see it this way, let me try and explain.

Developers: 
- Hey, why you gave us a hand saw while we clearly need a chainsaw?
Microsoft:
- Sorry, I can't give you a chainsaw, because you can cut your fingers. That doesn't mean I think you're dumb, of course.
Developers:
- But we need to cut through the forest! And it's our fingers, not yours - let us decide whether to risk them or not, ok?
Microsoft:
- Sorry, I can't see the forest in the toy apps and demos I've built for you.
Developers:
- Well, your 10K LOC demos aren't our 1M LOC codebases. That's why you don't see a plethora of other problems too.
Microsoft:
- I still insist my decision is correct. Also notice that I let you use a knife in addition to hand saw.
...

Note also that this is not about the size of the app on LOC, but about the complexity of the UI (Number of components rendered on screen).

Finally, I would say that we are talking about performance without having any explicit numbers attached to it (For example, with a scheme like this, how many components avoid re-rendering, and how much CPU time / frames per second we gain).

We can theorize of possible fixes and how they will impact perf, but what might impact an app, might not impact many other apps out there and might be in fact harmful, and offering such a general solution like this can also be equated to killing flies with cannonballs.

In other words, for us to be open to making this core piece extensible we would need:

I understand that this is not what you want to hear, and that it doesn't align with your views, but I hope that you understand that as framework authors we put in contrast other principles/values when we make design trade-offs that might not be weighted in the same way according to your own views.

alexyakunin commented 1 month ago

We don't believe excessive re-renders because of parameter comparisons represent a problem that spreads across many components in a given app.

I love your level of confidence :)

Ok, maybe it makes sense to point to one obvious case: typed identifiers.

Now ask a random Blazor developer about the effect of trying to do this - at scale.

alexyakunin commented 1 month ago

Libraries can still provide types that implement IBlazorImmutable that when used on components will have an impact.

YES, AND THAT'S EXACTLY THE REASON TO ADD IT!

I feel like I talk with ChatGPT here. Or maybe there is an unspoken rule in Microsoft, which goes like this: "If you're brave enough to suggest some change, make sure it changes nothing. Absolutely. Otherwise, it's a no-go."

It's amazing to see that at the very same company one group of people work on language with a decently complex type system that brings type safety & lots of other benefits, if you use it right. And there is another group, who literally say, "We're absolutely sure a dozen of primitive types we pre-selected covers every single case you can dream of. Oh, wait - we almost forgot to add Guid there!" , and all of this is justified by... Presumed stupidity of an average developer, right?

Sorry, but "640KB should be enough for everyone" is way more logical than this.

alexyakunin commented 1 month ago

The more I think about this, the worse it becomes.

Did you guys ever dream of making Equals and GetHashCode sealed? Please be honest. Just think about the amount of pain a single override like GetHashCode() -> Random.Shared.Next() may cause.

dotnet-policy-service[bot] commented 1 month ago

This issue has been resolved and has not had any activity for 1 day. It will be closed for housekeeping purposes.

See our Issue Management Policies for more information.