[net11.0] Improve TypedBinding performance#32382
Conversation
|
Just awesome. This will have a huge impact on all apps! |
eb36fe4 to
9046e23
Compare
There was a problem hiding this comment.
Pull Request Overview
This PR refactors the TypedBinding<TSource, TProperty> class to introduce a new, more efficient constructor and internal architecture for handling property change notifications. The main changes include:
- Introduces a new constructor that accepts a handlers function instead of a pre-built array, improving performance by deferring handler evaluation
- Adds internal
IPropertyChangeHandlerinterface with two implementations:LegacyPropertyChangeHandler(maintains backward compatibility) andPropertyChangeHandler(new efficient implementation) - Updates benchmark code to test both old and new approaches
- Adds comprehensive unit tests for the new constructor in
TypedBinding2UnitTests.cs - Removes the
TypedBinding.ForSingleNestingLevelfactory method and updates its single usage
Reviewed Changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| TypedBinding.cs | Core refactoring: removes static factory, adds new constructor with handler function parameter, introduces handler abstraction with two implementations |
| TypedBindingBenchmarker.cs | Expands benchmarks to compare old vs new binding approaches across multiple scenarios (simple, nested, indexed, two-way) |
| TypedBinding2UnitTests.cs | New comprehensive test file covering all binding scenarios with the new constructor |
| TypedBindingUnitTests.cs | Updates reflection code to access handlers through new _propertyChangeHandler field |
| TemplatedItemsList.cs | Updates single usage of removed factory method to use new constructor directly |
| BindingExpressionHelper.cs | Minor nullable annotation fix |
Comments suppressed due to low confidence (1)
src/Controls/tests/Core.UnitTests/TypedBinding2UnitTests.cs:1
- This reflection code accesses fields on what appears to be the new
PropertyChangeHandlerclass, but that class doesn't have a_handlersfield - it has_listeners. This code will fail at runtime if the new constructor path is taken. The test should be updated to work with both handler implementations or check which implementation is in use.
using System;
|
/azp run MAUI-UITests-public |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
@copilot there as a UI test failing and I am not sure if it can be related to these changes or not: Analyze this scenario and correlate it to the changes in this PR. Suggest improvements. |
|
@simonrozsival I've opened a new pull request, #32642, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@StephaneDelcroix I think it would be better to introduce this only in .NET 11 and not in .NET 10 SR. We might need to wait until there's a net11 branch. I also noticed I have not added the new public constructor to the Unshipped Public API documents... if this goes into .NET 11, I will do that in a separate PR. |
|
/rebase |
9046e23 to
179d216
Compare
you can wrap this code in #if NET_11_0_OR_GREATER |
|
/azp run MAUI-UITests-public |
|
Azure Pipelines successfully started running 1 pipeline(s). |
179d216 to
a22c6a0
Compare
|
/azp run |
|
Azure Pipelines could not run because the pipeline triggers exclude this branch/path. |
|
/rebase |
|
Hi Simon, Thanks for the update! I wanted to highlight a regression introduced by this change that affects TypedBinding with nested property paths. In previous MAUI versions, TypedBinding would re-establish INotifyPropertyChanged subscriptions whenever any object along the binding path changed, even if the binding had already been initialized once. This is essential for scenarios where the parent object starts as null, later becomes non-null, and then one of its nested objects changes. With this PR, when the binding engine detects that a subscription has already been created during binding initialization, it skips re-subscribing to PropertyChanged on updated source objects. As a result:
Changes: Thanks! |
0e74370 to
aa710f6
Compare
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 32382Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 32382" |
…ate becomes non-null When an intermediate object in a TypedBinding path starts as null and later becomes non-null (or is replaced with a different object), the binding must re-establish INPC subscriptions to the new object's nested properties. The previous optimization used a '_isSubscribed' boolean flag to skip IPropertyChangeHandler.Subscribe() after the first Apply. This prevented re-subscribing when intermediate objects changed. For example: vm.Child = null // subscribe to vm, but NOT vm.Child.Name (null) vm.Child = new Child() // Subscribe skipped due to _isSubscribed=true vm.Child.Name = "x" // binding never fires — regression! The fix removes '_isSubscribed' and always calls Subscribe on each Apply. Both IPropertyChangeHandler implementations (PropertyChangeHandler and LegacyPropertyChangeHandler) are already idempotent — they use reference equality checks to avoid re-subscribing to unchanged objects — so calling Subscribe on every Apply is safe and has minimal overhead. Adds two regression tests: - TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull - TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced Reported in: #32382 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@simonrozsival I think this should also be merged into main/inflight branches? |
|
@lucastitus I don't intend to backport this to .NET 10 servicing release unless there's a good reason to do so. |
|
@simonrozsival You've already merged your initial commits: acc2476 However you did not merge the recent null intermediary binding fix I flagged. Any upcoming .NET 10 servicing releases will have the binding issues mentioned currently. |
|
@simonrozsival As expected, this regression has made its way into release 10.0.50 |
|
@lucastitus can you please open an issue with a repro project? or is there one open already? |
5d6e5a2 to
03ba0b7
Compare
…ate becomes non-null When an intermediate object in a TypedBinding path starts as null and later becomes non-null (or is replaced with a different object), the binding must re-establish INPC subscriptions to the new object's nested properties. The previous optimization used a '_isSubscribed' boolean flag to skip IPropertyChangeHandler.Subscribe() after the first Apply. This prevented re-subscribing when intermediate objects changed. For example: vm.Child = null // subscribe to vm, but NOT vm.Child.Name (null) vm.Child = new Child() // Subscribe skipped due to _isSubscribed=true vm.Child.Name = "x" // binding never fires — regression! The fix removes '_isSubscribed' and always calls Subscribe on each Apply. Both IPropertyChangeHandler implementations (PropertyChangeHandler and LegacyPropertyChangeHandler) are already idempotent — they use reference equality checks to avoid re-subscribing to unchanged objects — so calling Subscribe on every Apply is safe and has minimal overhead. Adds two regression tests: - TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull - TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced Reported in: #32382 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ate becomes non-null When an intermediate object in a TypedBinding path starts as null and later becomes non-null (or is replaced with a different object), the binding must re-establish INPC subscriptions to the new object's nested properties. The previous optimization used a '_isSubscribed' boolean flag to skip IPropertyChangeHandler.Subscribe() after the first Apply. This prevented re-subscribing when intermediate objects changed. For example: vm.Child = null // subscribe to vm, but NOT vm.Child.Name (null) vm.Child = new Child() // Subscribe skipped due to _isSubscribed=true vm.Child.Name = "x" // binding never fires — regression! The fix removes '_isSubscribed' and always calls Subscribe on each Apply. Both IPropertyChangeHandler implementations (PropertyChangeHandler and LegacyPropertyChangeHandler) are already idempotent — they use reference equality checks to avoid re-subscribing to unchanged objects — so calling Subscribe on every Apply is safe and has minimal overhead. Adds two regression tests: - TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull - TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced Reported in: #32382 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ate becomes non-null When an intermediate object in a TypedBinding path starts as null and later becomes non-null (or is replaced with a different object), the binding must re-establish INPC subscriptions to the new object's nested properties. The previous optimization used a '_isSubscribed' boolean flag to skip IPropertyChangeHandler.Subscribe() after the first Apply. This prevented re-subscribing when intermediate objects changed. For example: vm.Child = null // subscribe to vm, but NOT vm.Child.Name (null) vm.Child = new Child() // Subscribe skipped due to _isSubscribed=true vm.Child.Name = "x" // binding never fires — regression! The fix removes '_isSubscribed' and always calls Subscribe on each Apply. Both IPropertyChangeHandler implementations (PropertyChangeHandler and LegacyPropertyChangeHandler) are already idempotent — they use reference equality checks to avoid re-subscribing to unchanged objects — so calling Subscribe on every Apply is safe and has minimal overhead. Adds two regression tests: - TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull - TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced Reported in: #32382 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Squashed 26 commits for clean rebase onto net11.0.
Copy-paste error: 6 field assignments were duplicated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
08f94d7 to
b687e4c
Compare
|
@StephaneDelcroix @jfversluis could you please have a look at this PR and let me know what you think about it? could we merge this into net11.0 soon? |
|
|
|
/review -b feature/refactor-copilot-yml |
|
|
AI code review for net11.0 targetAutomated, non-approval review comment. This does not constitute a human approval and intentionally does not use GitHub's Approve/Request-changes. Verdict: Needs changes (code itself looks good; blocked on rebase + CI re-validation) What this PR doesReworks Findings
CI noteLast build (1344637) is stale and predates current Confidence: Medium-high on the code; low on CI (stale/conflicting). |
kubaflo
left a comment
There was a problem hiding this comment.
AI cross-model review — PR #32382 ([net11.0] Improve TypedBinding performance)
Verdict: NEEDS_CHANGES (non-approval, automated synthesis of 4 independent model reviews; does not use GitHub Approve/Request-changes)
All four models independently returned NEEDS_CHANGES. The PR reworks TypedBinding<TSource,TProperty> to a new (getter, setter, handlersCount, GetHandlers) constructor backed by a lazy PropertyChangeHandler, and teaches both source generators (BindingSourceGen C# and CompiledBindingMarkup XAML) to emit INPC-aware handlers — a real 2–3× steady-state win. Synthesis kept 4 code-confirmed correctness bugs and dropped 2 lower-value items (a per-notification Action allocation in OnPropertyChanged and a _listeners[i] = null micro-opt) as non-blocking.
Key findings (all validated against HEAD b687e4c)
- error ·
CompiledBindingMarkup.cs:695(consensus 3/4) —MaybeImplementsINPCindex branch emits twoyield returns but incrementshandlersCountonce →_listenersundersized → index-specific (Item[n]) listener silently dropped. ParallelDefinitelyImplementsINPCbranch increments twice. Precise fix suggested. - error ·
CompiledBindingMarkup.cs:729(consensus 3/4) — same undercount in theConditionalAccess→IndexAccessbranch. Precise fix suggested. - error ·
BindingCodeWriter.cs:696(consensus 1/4) — unsafe-getter emission gated solely onhasSubsequentHandler; a writable two-way binding to a non-last inaccessible-getter property whose type isn't INPC still referencesGetX(...)fromSetter.From→ExtendExpression, but the accessor is omitted → uncompilable generated code (value getter reuses the user lambda, so one-way bindings hide it). - error ·
TypedBinding.cs:667(consensus 1/4) —Subscribe(part, OnPropertyChanged)passes a method-group delegate stored only asWeakReferencebyWeakEventProxy(WeakEventProxy.cs:50); nothing roots it, so after GC the proxy self-Unsubscribe()s and the binding silently stops updating. ExistingBindingExpressionPartcaches_changeHandlerin a field to avoid exactly this.
Context (not posted inline)
A user (lucastitus) reported a related null-intermediary re-subscription regression; it could not be statically confirmed at this SHA and may predate the current diff, so it is noted here rather than as an inline finding. Re-verify with a repro before merge.
CI
PR-failing and not mergeable: maui-pr, Pack macOS, and Helix Unit Tests Windows (Debug & Release) are red, plus Build Analysis fails and the branch is in merge conflict. The failing Windows unit-test legs exercise the binding/source-generator code this PR changes, so they must be treated as potentially PR-caused (consistent with the handler-undercount and generated-code bugs above) until a fresh green run proves otherwise — do not LGTM over them. Rebase, fix the findings, and require a green Helix unit-test run before merge.
Fix generated handler counts for maybe-INPC indexer bindings, keep typed binding property-change subscriptions alive without a cached delegate field, and ensure unsafe getters are emitted when generated setters need them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cover the new property-change listener path so subscriptions continue to deliver updates after delegate-collecting garbage collections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address the remaining PR review comment about the extra space before readonly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve TypedBinding and PublicAPI conflicts after updating from origin/net11.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
kubaflo
left a comment
There was a problem hiding this comment.
AI cross-model re-review — PR #32382 ([net11.0] Improve TypedBinding performance)
Verdict: NEEDS_CHANGES (non-approval automated synthesis of 4 independent model re-reviews; does not use GitHub Approve/Request-changes)
Re-review after your update: thanks for the new commit — all three prior code errors are verified fixed against HEAD f11ef00. The remaining reasons for NEEDS_CHANGES are one newly-introduced behavioral redundancy and the still-pending binding-critical CI legs. Two of the four models re-flagged already-fixed issues against stale line numbers; those are false positives and are not reposted.
✅ Fixed (verified in code)
- Prior #1 —
handlersCountundercount (CompiledBindingMarkup.cs). Both the directIndexAccessand theConditionalAccess→IndexAccessmaybe-INPC branches now dohandlersCount += CountIndexAccessHandlers(...), which returns2whenDefaultMemberNameis set (matching the twoyield returns) and1otherwise._listenersis correctly sized; theItem[n]listener is no longer dropped. - Prior #2 — missing unsafe getter for a two-way setter (
BindingCodeWriter.cs). The emit condition now includesneedsGetterForSetter = binding.SetterOptions.IsWritable && !isLastPart, so a writable binding to a non-last inaccessible-getter property emitsGetX(...)regardless ofhasSubsequentHandler. The generated setter compiles (integration test covers it). - Prior #3 — weakly-rooted
OnPropertyChangeddelegate (TypedBinding.cs). The subscription path was redesigned:PropertyChangeListenersubscribes its own instance method (source.PropertyChanged += OnPropertyChanged), is strongly rooted by_listeners[]and by the source event, and only weakly references the owning handler. The binding strongly holds the handler (readonly IPropertyChangeHandler _propertyChangeHandler). No weakly-stored delegate remains; the added GC unit test exercises this. - Prior #4 (context note) — null-intermediary re-subscription. The new
Subscribebreaks on a null part and trims trailing listeners viaUnsubscribe(startIndex), then re-subscribes on the next apply. Not reposted.
⚠️ Still-open / new — 1 inline comment
- warning ·
TypedBinding.cs:675— duplicate source subscription →Applyruns twice. An indexer with aDefaultMemberNameyields the same INPC object twice ("Item"+"Item[n]"), so two listeners subscribe to the samePropertyChanged. One notification invokes the handler twice andShouldApplyChanges(l.690) returns true for each, dispatchingApplytwice. The bound value stays correct (BindableProperty dedups the redundant write), so it is not a correctness break — but it is a new behavioral regression that re-evaluates the getter/converter twice on every indexer change, at odds with the PR's perf goal. Coalesce duplicate subscriptions into one listener with multiple expected names, or filter per-listener.
Corrected false positives (not posted)
geminire-flagged thehandlersCountundercount atCompiledBindingMarkup.cs:695/729(the old line numbers) — already fixed byCountIndexAccessHandlers; stale.opus-4.8re-flagged the weakSubscribe(part, OnPropertyChanged)delegate atTypedBinding.cs:667— that signature no longer exists (the listener now subscribes directly). Its CI claim (Pack macOS / Helix failing, merge conflict) is also stale.gemini's_listeners[i] = nullmicro-opt was already considered and dropped as non-blocking in the prior review.
CI
Not PR-failing, but not yet green. macOS Build (Debug/Release) and Pack macOS pass; Windows Build (Debug/Release), Pack Windows, Helix Unit Tests Windows (Debug & Release), and Build Analysis are still pending. The Helix Windows unit-test legs exercise exactly the binding/source-generator code this PR reworks, so merge-readiness cannot be confirmed until they report green. No leg is currently red.
Confidence: medium — the three prior fixes are verified with high confidence; the verdict turns on the new double-apply behavior plus the still-pending binding-critical CI.
|
|
||
| // Different object or first subscription, unsubscribe from old and subscribe to new | ||
| listener.Unsubscribe(); | ||
| listener.Subscribe(part); |
There was a problem hiding this comment.
Duplicate subscription to the same source → Apply runs twice per notification. For an indexer with a non-empty DefaultMemberName, the generated GetHandlers yields the same INotifyPropertyChanged instance twice — (obj, "Item") and (obj, "Item[n]"). This loop therefore creates two separate PropertyChangeListeners (line 663) and subscribes both to obj.PropertyChanged (this line). A single PropertyChanged("Item[n]") then invokes the handler twice (once per listener), and ShouldApplyChanges (line 690) returns true for each invocation because it scans all listeners for a matching (sender, propertyName) — so Apply(fromTarget: false) is dispatched twice instead of once.
The final bound value stays correct (the redundant SetValue is deduped by BindableProperty equality), so this is not a correctness break, but it is a new behavioral regression in a perf-focused PR: every indexer notification re-evaluates the getter/converter twice, whereas the old TypedBinding yielded a single indexer handler and applied once.
Consider coalescing duplicate source subscriptions into one listener that carries multiple expected property names, or moving the per-listener name filter into PropertyChangeListener so only the listener whose expected name matches invokes ApplyChanges.
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description of Change
This PR improves source-generated
TypedBindingperformance by changing how generated bindings describe and subscribe to property-change handlers.The old generated path eagerly allocated a fixed
Tuple<Func<TSource, object>, string>[]handler array. The new generated path uses:handlersCount- a compile-time count used to size listener storage.GetHandlers(TSource source)- a lazy iterator that yields only theINotifyPropertyChangedsources that actually exist for the current binding source.This lets source-generated bindings avoid eager handler-array allocation and skip handler work for path parts that cannot raise
PropertyChanged.Key Technical Details
Runtime
TypedBindingTypedBinding<TSource, TProperty>constructor that acceptshandlersCountandGetHandlers.PropertyChangeHandler/PropertyChangeListenerpath for generated bindings.PropertyChangeListenersubscribe directly and weakly reference the owningPropertyChangeHandler.C# BindingSourceGen
TypedBindingconstructor shape.INotifyPropertyChanged.is INotifyPropertyChangedchecks for maybe-INPC types.XAML SourceGen
handlersCount+GetHandlersconstructor shape.CountIndexAccessHandlers(...)so both default member notifications (for exampleItem) and index-specific notifications (for exampleItem[2]) are subscribed when both are generated.Review Fixes Included
The latest review feedback is addressed in this PR:
UnsafeAccessorgetter emission for writable bindings whose setter still needs a non-last inaccessible getter.ItemandItem[n].PropertyChangeHandlerusing primary-constructor style.protected readonlytypo.Why the Diff Is Large
The PR currently shows about +3990 / -871 lines across 29 files. Most of that churn is validation and benchmark coverage, not product implementation.
TypedBinding2UnitTests.cs(+2094), source-generator integration/snapshot updatesTypedBinding, C# BindingSourceGen, XAML SourceGen, path analysis helpers, PublicAPIThe largest single file is
TypedBinding2UnitTests.cs, which duplicates the existing typed-binding behavior matrix against the new generated-binding constructor path. Reducing that further would require a broader test harness refactor to share the old/new constructor matrix; that can be done separately, but this PR keeps the coverage explicit so the runtime behavior comparison is easy to review.Benchmark Results
Latest local run:
Environment: BenchmarkDotNet 0.13.10, macOS 26.5, Apple M1 Max, .NET SDK 11.0.100-preview.5.26302.115.
Steady-state (property value already applied)
Setup path (binding creation + first apply)
The new constructor path is about 2.1-2.8x faster on setup than the old
TypedBindingconstructor path and allocates less. In the steady-state property-change path,TypedBinding2is about 6% faster than oldTypedBindingwith the same allocation, while source-generated binding is effectively tied withTypedBinding2in this run.Tests
Issues Fixed
No tracked issue.