Closed alexyakunin closed 1 month 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.
@javiercn sorry, but I can't agree with you:
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 ?
@alexyakunin thanks for the additional details.
A few things:
Equals
rather than our own interface.ParameterView
into a dictionary and comparing them against the new ones.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.
@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:
Equals
can be expensive.So IMO what's already done is absolutely correct. And my suggestion doesn't break any benefits of existing soution.
IBlazorImmutable
is a very explicit action.IBlazorImmutable
, it's reasonable to assume you know the consequences of this.In other words, I really see no cons.
- 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.
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 overridingSetParametersAsync
. 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.
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:
@alexyakunin thanks for the additional details.
I think we disagree at which level this should happen. For us:
Some more details:
IComponentParametersComparer
service that you could register on DI to provide your own implementation, we would open the door for anyone changing when/how third-party components render.
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.
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:
@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:
IBlazorImmutable
that when used on components will have an impact.Dictionary<string,object>
.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.
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.
UserId
. And the later are much more convenient to use - e.g. you won't pass UserId
to a method expecting OrderId
.Now ask a random Blazor developer about the effect of trying to do this - at scale.
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.
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.
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.
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 taggingIBlazorImmutable
(the name is questionable, maybe we needIImmutable
in .NET?) and use it instead inIsKnownImmutableType
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
Detailed design