Skip to content

[net11.0] Improve TypedBinding performance#32382

Open
simonrozsival wants to merge 2 commits intonet11.0from
dev/srozsival/binding-improvements
Open

[net11.0] Improve TypedBinding performance#32382
simonrozsival wants to merge 2 commits intonet11.0from
dev/srozsival/binding-improvements

Conversation

@simonrozsival
Copy link
Copy Markdown
Member

@simonrozsival simonrozsival commented Nov 4, 2025

Description

This PR improves the source-generated TypedBinding implementation with several enhancements:

1. New TypedBinding constructor with handlersCount + GetHandlers delegate

Replaces the old constructor that accepted a fixed-size Tuple<Func<>, string>[] with a new one taking:

  • int handlersCount — pre-computed count of property-change handlers
  • Func<TSource, IEnumerable<(INotifyPropertyChanged, string)>>? getHandlers — lazy handler enumeration

This eliminates closure allocations and avoids eagerly creating handler arrays at binding setup time.

2. INPC-aware handler generation

The source generators (both C# BindingSourceGen and XAML SourceGen) now analyze types at compile time to determine their INPC status:

  • DefinitelyImplementsINPC — sealed types or types with INPC in AllInterfaces → direct yield return without runtime check
  • MaybeImplementsINPC — type parameters, interfaces, unsealed classes → if (x is INPC p) yield return ...
  • No handler — structs, sealed classes without INPC → skipped entirely

3. Dead-code elimination in generated handlers

When the remaining path parts will not produce any yield return statements (e.g., because the type is a struct), the generator now skips emitting the var pN = expr; assignment. This also eliminates the corresponding UnsafeAccessor method that would have been generated for the dead variable.

4. Cast combination & conditional access propagation

  • Cast operations (as X) following member access are combined into a single variable assignment
  • Conditional access (?.) is properly propagated after nullable casts

5. Benchmark pragma fixes

Added #pragma warning disable RS0030 to benchmark files to allow comparing old vs new TypedBinding constructors (old constructor is banned via eng/BannedSymbols.txt).

Benchmark Results

Steady-state (property value already applied)

Method Mean Ratio Allocated
SetValue 39.16 ns 1.00 48 B
Binding 92.30 ns 2.41 128 B
TypedBinding (old) 59.19 ns 1.54 64 B
TypedBinding2 (new) 12.80 ns 0.33 48 B
SourceGeneratedBinding 12.30 ns 0.31 48 B

Setup path (binding creation + first apply)

Benchmark TypedBinding2 (new) TypedBinding (old) Binding New/Old Ratio
Name 551 ns / 800 B 983 ns / 896 B 1,350 ns / 1,064 B 0.58x
NameTwoWay 570 ns / 800 B 1,058 ns / 896 B 1,211 ns / 1,064 B 0.54x
Child 1,011 ns / 1.2 KB 2,101 ns / 1.44 KB 2,035 ns / 1.61 KB 0.49x
ChildTwoWay 848 ns / 1.2 KB 1,736 ns / 1.44 KB 2,415 ns / 1.61 KB 0.48x
ChildIndexed 948 ns / 1.2 KB 2,562 ns / 1.7 KB 4,325 ns / 2.56 KB 0.38x
ChildIndexedTwoWay 1,002 ns / 1.2 KB 2,516 ns / 1.7 KB 4,040 ns / 2.56 KB 0.41x

The new implementation is 2-3x faster at steady state and ~2x faster on the setup path compared to the old TypedBinding, while also reducing memory allocations.

@bcaceiro
Copy link
Copy Markdown

bcaceiro commented Nov 4, 2025

Just awesome. This will have a huge impact on all apps!

@simonrozsival simonrozsival changed the title [WIP] Improve TypeBinding performance Improve TypeBinding performance Nov 7, 2025
@simonrozsival simonrozsival force-pushed the dev/srozsival/binding-improvements branch from eb36fe4 to 9046e23 Compare November 7, 2025 10:54
@simonrozsival simonrozsival marked this pull request as ready for review November 7, 2025 10:55
Copilot AI review requested due to automatic review settings November 7, 2025 10:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IPropertyChangeHandler interface with two implementations: LegacyPropertyChangeHandler (maintains backward compatibility) and PropertyChangeHandler (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.ForSingleNestingLevel factory 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 PropertyChangeHandler class, but that class doesn't have a _handlers field - 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;

@simonrozsival simonrozsival changed the title Improve TypeBinding performance Improve TypedBinding performance Nov 7, 2025
@simonrozsival simonrozsival enabled auto-merge (squash) November 14, 2025 09:43
@simonrozsival
Copy link
Copy Markdown
Member Author

/azp run MAUI-UITests-public

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@StephaneDelcroix StephaneDelcroix added this to the .NET 10 Servicing milestone Nov 14, 2025
@simonrozsival
Copy link
Copy Markdown
Member Author

@copilot there as a UI test failing and I am not sure if it can be related to these changes or not:

Failed VerifyHybridWebView_EvaluateJavaScriptWithDifferentHybridRoot [19 s]
  Error Message:
     JavaScript evaluation should return the correct title for HybridWebview1.
Assert.That(result, Is.EqualTo("EvaluateJavaScriptAsync Result: HybridWebView1"))
  Expected string length 46 but was 32. Strings differ at index 32.
  Expected: "EvaluateJavaScriptAsync Result: HybridWebView1"
  But was:  "EvaluateJavaScriptAsync Result: "
  -------------------------------------------^

  Stack Trace:
     at Microsoft.Maui.TestCases.Tests.HybridWebViewFeatureTests.VerifyHybridWebView_EvaluateJavaScriptWithDifferentHybridRoot() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/HybridWebViewFeatureTests.cs:line 80

1)    at Microsoft.Maui.TestCases.Tests.HybridWebViewFeatureTests.VerifyHybridWebView_EvaluateJavaScriptWithDifferentHybridRoot() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/HybridWebViewFeatureTests.cs:line 80

Analyze this scenario and correlate it to the changes in this PR. Suggest improvements.

Copy link
Copy Markdown
Contributor

Copilot AI commented Nov 15, 2025

@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.

@simonrozsival
Copy link
Copy Markdown
Member Author

simonrozsival commented Nov 15, 2025

@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.

@simonrozsival
Copy link
Copy Markdown
Member Author

/rebase

@github-actions github-actions bot force-pushed the dev/srozsival/binding-improvements branch from 9046e23 to 179d216 Compare November 24, 2025 09:05
@StephaneDelcroix
Copy link
Copy Markdown
Contributor

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.

you can wrap this code in #if NET_11_0_OR_GREATER

@simonrozsival
Copy link
Copy Markdown
Member Author

/azp run MAUI-UITests-public

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@simonrozsival simonrozsival force-pushed the dev/srozsival/binding-improvements branch from 179d216 to a22c6a0 Compare December 4, 2025 08:12
@simonrozsival simonrozsival changed the base branch from main to net11.0 December 4, 2025 08:12
@simonrozsival simonrozsival changed the title Improve TypedBinding performance [net11.0] Improve TypedBinding performance Dec 4, 2025
@simonrozsival
Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines could not run because the pipeline triggers exclude this branch/path.

@simonrozsival
Copy link
Copy Markdown
Member Author

/rebase

@simonrozsival simonrozsival force-pushed the dev/srozsival/binding-improvements branch 2 times, most recently from 89166be to 6cf43dc Compare February 13, 2026 12:25
@simonrozsival
Copy link
Copy Markdown
Member Author

simonrozsival commented Feb 13, 2026

One important aspect: this new constructor isn't generated by either source generator. That will require a separate PR.

@lucastitus
Copy link
Copy Markdown

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:

  • If the root or any intermediate object in the binding path starts as null,
  • TypedBinding resolves the path once, does not subscribe to the deeper PropertyChanged watchers,
  • and then never updates when nested properties change later.

Changes:
6ce4e0f#diff-bee8ab0ca9d7b2fdc63c4a340be85c32c8fc33bd437e1e331a93dd84c77d19f3R277

Thanks!

@StephaneDelcroix StephaneDelcroix force-pushed the dev/srozsival/binding-improvements branch from 0e74370 to aa710f6 Compare February 27, 2026 15:39
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 27, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 32382

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 32382"

StephaneDelcroix added a commit that referenced this pull request Feb 27, 2026
…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>
@lucastitus
Copy link
Copy Markdown

@simonrozsival I think this should also be merged into main/inflight branches?

@simonrozsival
Copy link
Copy Markdown
Member Author

@lucastitus I don't intend to backport this to .NET 10 servicing release unless there's a good reason to do so.

@lucastitus
Copy link
Copy Markdown

@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.

@lucastitus
Copy link
Copy Markdown

@simonrozsival As expected, this regression has made its way into release 10.0.50

@simonrozsival
Copy link
Copy Markdown
Member Author

simonrozsival commented Mar 11, 2026

@lucastitus can you please open an issue with a repro project? or is there one open already?

/cc @StephaneDelcroix

@simonrozsival simonrozsival force-pushed the dev/srozsival/binding-improvements branch from 5d6e5a2 to 03ba0b7 Compare March 11, 2026 09:51
StephaneDelcroix added a commit that referenced this pull request Mar 12, 2026
…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>
github-actions bot pushed a commit that referenced this pull request Mar 12, 2026
…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>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
…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 and others added 2 commits March 20, 2026 10:07
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>
@simonrozsival simonrozsival force-pushed the dev/srozsival/binding-improvements branch from 08f94d7 to b687e4c Compare March 20, 2026 09:08
@simonrozsival
Copy link
Copy Markdown
Member Author

@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?

@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented Mar 22, 2026

⚠️ Merge Conflict Detected — This PR has merge conflicts with its target branch. Please rebase onto the target branch and resolve the conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants