Skip to content

Add compile-time property initializer for default values in BindableProperty source generaor#3204

Merged
TheCodeTraveler merged 14 commits intoCommunityToolkit:mainfrom
matt-goldman:add-default-value-init
Apr 19, 2026
Merged

Add compile-time property initializer for default values in BindableProperty source generaor#3204
TheCodeTraveler merged 14 commits intoCommunityToolkit:mainfrom
matt-goldman:add-default-value-init

Conversation

@matt-goldman
Copy link
Copy Markdown
Contributor

Description of Change

The source generator wraps all property initializers in a defaultValueCreator delegate. This causes intermittent failures in unit tests:

ArgumentException: An item with the same key has already been added. Key: Microsoft.Maui.Controls.BindableProperty

This could also theoretically surface in production code with concurrent property access, which is highly unlikely as test context differs in some important ways (specifically having a UI thread/synchronization context), but possible.

Root cause:

This was quite hard to track down. BindableProperty.Create with defaultValueCreator: uses a lazy-initialization path, that eventually hits BindableObject.GetValue() that performs a dictionary write on first access. Dictionary is not threadsafe, and when multiple threads access the property concurrently before it has been initialized, the dictionary write races and throws. Hand-written BindableProperties using defaultValue: follow a pure-read path and never hit this race.

I note that there were some protections against this in the code already (volatile on the init flag), but this wasn't working as expected. The documentation for the volatile keyword notes that the affected field is excluded from certain optimizations to help with concurrency, but warnings highlight that higher-level alternatives are recommended, and specifically:

On a multiprocessor system, a volatile read operation doesn't guarantee to obtain the latest value written to that memory location by any processor. Similarly, a volatile write operation doesn't guarantee that the value written is immediately visible to other processors.

Presumably this is the scenario encountered during tests.

Two other changes attempted to address this:

  • Removing ConfigureAwait(false): this is correct as in UI bound operations the bindable property has thread affinity, and the operation should always return to the caller, but while this seemed to help, it didn't resolve the issue.
  • Adding [ThreadStatic] to the initialized flag: appeared to resolve the problem at first and had a bigger impact, but was not reliably consistent. As mentioned above, eventually we hit the internal values dictionary in BindableObject.GetValue() and there's no way around this if using defaultValueCreator.

Solution:

The fundamental problem is using static values in bindable property generators in multi-threaded scenarios (as noted in #3096, see below linked issues). This was required in some scenarios (and still is, the current approach is retained as a fallback), but it's also possible to infer the default value at compile time.

A new resolver/helper parses the initializer expression and generates fully-qualified C# code at compile time. This completely eliminates the issue as the code path that hits the BindableObject.GetValue method at runtime never encounters an empty value. The bindable properties in the toolkit have been updated to use this, which resolves all build issues. I've successfully re-run this several times with no failed builds.

Summary of changes:

  • New InitializerExpressionResolver: This is the core of the change. A helper resolves property initializer expressions (literals, enum members, default, new T(), static field/property references, casts, etc.) to fully-qualified C# code at compile time. Unresolvable expressions (collection expressions [], lambdas, complex method calls) return null and fall back to the existing defaultValueCreator path.

  • Updated BindablePropertyAttributeSourceGenerator: When the resolver succeeds, emits the resolved expression directly as the defaultValue: parameter instead of wrapping it in a defaultValueCreator delegate. When it cannot resolve (or when the user explicitly sets DefaultValueCreatorMethodName), behavior is unchanged.

  • ValidationBehavior fix: Removed .ConfigureAwait(false) from three async void override methods (OnAttachedTo, OnViewPropertyChanged, OnValidationPropertyChanged). In async void methods, .ConfigureAwait(false) can cause continuations to run on thread pool threads, leading to race conditions in test environments without a SynchronizationContext. Added try/catch to OnValidationPropertyChanged with Options.ShouldSuppressExceptionsInBehaviors guard.

  • PopupServiceTests fix: Changed Windows[0] to Windows.FirstOrDefault() in debug logging to prevent ArgumentOutOfRangeException when no windows exist during test teardown. (Unrelated issue but was the only thing blocking a clean build so I added this).

Linked Issues

PR Checklist

  • Has a linked Issue, and the Issue has been approved(bug) or Championed (feature/proposal)
  • Has tests (if omitted, state reason in description)
  • Has samples (if omitted, state reason in description)
  • Rebased on top of main at time of PR
  • Changes adhere to coding standard
  • Documentation created or updated: No documentation changes as it is not a public facing API change, and I don't think there are docs that cover this yet anyway.

Additional information

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 updates the BindableProperty source generator to avoid emitting defaultValueCreator delegates when a property’s initializer can be resolved into a direct defaultValue expression, addressing race conditions seen in multi-threaded test runs. It also includes a couple of test-stability fixes in the toolkit.

Changes:

  • Added an initializer-expression resolver and updated the BindableProperty generator/model to emit defaultValue: directly when possible.
  • Updated generator unit tests to reflect the new generated output.
  • Removed .ConfigureAwait(false) from async void behavior hooks (and added suppression logging), plus a small PopupService unit test logging fix.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/CommunityToolkit.Maui/Behaviors/Validators/ValidationBehavior.shared.cs Removes .ConfigureAwait(false) from async void hooks; adds guarded exception suppression/logging.
src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs Makes debug logging resilient when Application.Current.Windows is empty.
src/CommunityToolkit.Maui.SourceGenerators/Models/BindablePropertyRecords.cs Extends the model to carry a resolved initializer expression and adjusts decision flags.
src/CommunityToolkit.Maui.SourceGenerators/Helpers/InitializerExpressionResolver.cs New helper to resolve initializer syntax into emitted C# expressions for defaultValue:.
src/CommunityToolkit.Maui.SourceGenerators/Generators/BindablePropertyAttributeSourceGenerator.cs Emits resolved defaultValue: when available and threads it through semantic transform/model creation.
src/CommunityToolkit.Maui.SourceGenerators.UnitTests/...IntegrationTests.cs Updates expected generator output to match new defaultValue: emission behavior.
src/CommunityToolkit.Maui.SourceGenerators.UnitTests/...EdgeCaseTests.cs Updates expected output for more initializer shapes (including UtcNow, TimeSpan.FromSeconds, etc).
src/CommunityToolkit.Maui.SourceGenerators.UnitTests/...CommonUsageTests.cs Updates expected output to use direct defaultValue: where resolvable.

matt-goldman and others added 6 commits April 19, 2026 07:09
…ExpressionResolver.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ExpressionResolver.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ExpressionResolver.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

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

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

.github/instructions/codestyle.instructions.md:582

  • The added "Try Pattern" section starts a markdown code fence but never closes it (the file ends immediately after the opening ). This will break rendering for the rest of the document; either remove the stray fence or add a complete example and closing .
### Try Pattern

When a method name begins with `Try` and returns a variable, ensure it adheres to the Try Pattern.

Here Key Components of the Try Pattern:
1. Method Name: Begins with Try (e.g., TryParse, TryGetValue).
2. Return Type: bool (indicating success or failure).
3. Out Parameter: An out parameter to return the result if successful.
4. Null Analysis Attribute: [NotNullWhen(true)] (or [MaybeNullWhen(false)]) informs the compiler that if the method returns true, the out parameter is guaranteed to be non-null. 

This pattern allows for high-performance retrieval or parsing without throwing exceptions for expected failures. It also allows cleaner call sites by eliminating the need for null-checking the output variable within the if block, as seen in Dictionary<TKey, TValue>.TryGetValue. 
</details>

TheCodeTraveler and others added 2 commits April 19, 2026 15:17
…ExpressionResolver.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…tializerExpressionResolver.cs"

This reverts commit 375870b.
Copy link
Copy Markdown
Collaborator

@TheCodeTraveler TheCodeTraveler left a comment

Choose a reason for hiding this comment

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

You're a legend! Thanks Matt!!

@TheCodeTraveler TheCodeTraveler merged commit 09b80fa into CommunityToolkit:main Apr 19, 2026
9 of 10 checks passed
@matt-goldman matt-goldman deleted the add-default-value-init branch April 19, 2026 23:39
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Tests are Fragile

3 participants