Skip to content

Fix x:Bind function bindings not updating when an argument becomes null - #11732

Open
Sergio Pedri (Sergio0694) wants to merge 3 commits into
mainfrom
user/sergiopedri/xbind-nullable-function-arg
Open

Fix x:Bind function bindings not updating when an argument becomes null#11732
Sergio Pedri (Sergio0694) wants to merge 3 commits into
mainfrom
user/sergiopedri/xbind-nullable-function-arg

Conversation

@Sergio0694

@Sergio0694 Sergio Pedri (Sergio0694) commented Aug 31, 2026

Copy link
Copy Markdown
Member

Fixes

Fixes #1904

PR Type

  • Bugfix

Description

A one-way x:Bind function binding stops updating the moment its argument becomes null. Setting the property to a value works, changing it to a different value works, but clearing it does nothing — the UI keeps whatever it was last given. There's no binding error and nothing in the debug output, so everything points at the view model being wrong when it isn't.

The easiest way to hit it is a visibility helper:

<Grid Visibility="{x:Bind local:BindingFunctions.VisibleIfNotNull(Detail), Mode=OneWay}">

The grid appears once Detail is set, and then can never be hidden again — because hiding it depends on exactly the update that gets skipped. Anything else works the same way: a string? feeding a Text binding keeps the last non-empty string instead of clearing.

This is the same thing reported in #1904 back in 2020, there with a bool? and a static converter method:

<DataTemplate x:DataType="MyModel">
    <TextBlock Text="{x:Bind converters:MyConverter.Convert(Property), Mode=OneWay}"/>
</DataTemplate>

I ran both repros from that issue through the compiler, and the generated code goes from skipping the call to always making it.

Current Behavior

The generated code treats a null argument as "this path didn't resolve, don't bother", so it never calls the function:

private void Update_Model_NullableDoublePropertyDP(global::System.Nullable<global::System.Double> obj, int phase)
{
    if (obj != null)
    {
        this.Update_Model_M_FunctionReturningNullableDouble_1740600808(phase);
    }
    ...
}

But null isn't an unresolved path here, it's a perfectly good argument. And the code that actually calls the function already knows the difference — it looks each argument up through a TryGet_ helper that only gives up when something in the middle of the path is null:

private bool TryGet_Model_NullableDoublePropertyDP(out global::System.Nullable<global::System.Double> val)
{
    global::BindTestbedModel.DataModel obj;
    if (TryGet_Model(out obj) && obj != null)   // checks the parent, not the value
    {
        val = obj.NullableDoublePropertyDP;     // may legitimately be null, and that's fine
        return true;
    }
    ...
}

So that check on the way in is the only thing in the way. C++/WinRT already didn't emit it — C#, VB and C++/CX did.

New Behavior

It's gone, so the function runs and its result is applied:

 private void Update_Model_NullableDoublePropertyDP(global::System.Nullable<global::System.Double> obj, int phase)
 {
-    if (obj != null)
-    {
-        this.Update_Model_M_FunctionReturningNullableDouble_1740600808(phase);
-    }
+    this.Update_Model_M_FunctionReturningNullableDouble_1740600808(phase);
     if ((phase & ((1 << 0) | NOT_PHASED | DATA_CHANGED)) != 0)
     {
         // NullableTests.xaml line 39
         if (!isobj5NullableDoubleDPDisabled)
         {
             XamlBindingSetters.Set_BindTestbedModel_NullablePropertiesButton_NullableDoubleDP(this.obj5, obj, null);
         }
     }
 }

One thing that had to be fixed first

Removing that check on its own would have swapped a stale UI for a crash in one case, so the first commit closes that hole.

When a function is called on something — {x:Bind Model.Format(...)} rather than a static helper — the generated code walked straight to it without checking it was there:

global::System.String result = this.dataRoot.Model.FunctionOnModelOneStringArg(p0);

That's fine as long as the only way in is through Model itself, which the compiler does check. But a binding can also be woken up by one of its arguments, and an argument doesn't have to live under Model. {x:Bind Model.Format(Title)} with a null Model throws today the moment Title has a value; with the rest of this change it would also throw when both are null, which is a very ordinary state for a page that hasn't loaded its data yet.

So the instance now goes through the same lookup the arguments already use, and the call is made on what comes back:

 private void Invoke_Model_M_FunctionOnModelOneStringArg_1668010001(int phase)
 {
     global::System.String p0;
     if (!TryGet_BindTestbedModel_DataModel_StaticStringProperty(out p0)) { return; }
-    global::System.String result = this.dataRoot.Model.FunctionOnModelOneStringArg(p0);
+    global::BindTestbedModel.DataModel instance;
+    if (!TryGet_Model(out instance) || instance == null) { return; }
+    global::System.String result = instance.FunctionOnModelOneStringArg(p0);
     ...
 }

This is only emitted where an argument can reach the binding from outside the instance's own path — one binding across all four testbeds — so everything else generates exactly as before.

Customer Impact

User facing. Three things change for apps:

  • A function binding now runs when one of its arguments becomes null, instead of leaving the target on its previous value. This is the fix.
  • The same binding no longer throws when the object the function is called on is null and an unrelated argument changes.
  • FallbackValue no longer kicks in when an argument is simply null. It still applies when the path genuinely can't be resolved — a null somewhere in the middle — which lines up with how a plain binding behaves, where a null value at the end gives you TargetNullValue and only a broken path gives you FallbackValue. This is the one behaviour someone could have been relying on, and it's also the workaround people reach for when they hit the bug in the first place.

Regression Potential

  • Low risk — isolated change, limited scope

The check that was removed only ever guarded function bindings, and only on the way in. Everything else keeps its null checks, including walking down a path, and there are tests pinning that. C++/WinRT has generated the new shape all along, so this brings the other three languages in line rather than inventing something.

How Has This Been Tested?

  • I have performed a self-review of my own code
  • I have added tests to cover my changes
  • Existing tests pass locally

New codegen tests in FunctionBindingCodegenTests.cs cover C# and VB, plus two controls pinning the shapes that must not change. Confirmed against a locally built compiler, before and after:

before after
null argument still updates the target (C#) fails passes
null argument still updates the target (VB) fails passes
instance looked up when an argument sits outside its path fails passes
walking down a path still skips a null step (control) passes passes
no lookup when every argument sits under the instance (control) passes passes

The checked-in expected-codegen masters are updated to match, and the compiler builds clean for both target frameworks.

A function binding is scheduled either as a child of the step that produces the
instance the method is invoked on - which is only updated while that instance is
non null - or as a dependent of one of its path arguments, which carries no such
guarantee. In the second case the generated Invoke_ method dereferenced the
instance path directly, so {x:Bind Model.Fn(ArgumentOutsideModel)} threw when
Model was null even though every argument resolved.

Retrieve the instance through the same TryGet_ helpers already used for path
arguments, immediately before the call so argument evaluation order is
unchanged, and invoke the method on the retrieved local so the path is walked
once. The retrieval is only emitted where an argument sits outside the instance
path, which is the only shape that can reach a null instance.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…s null

Update_<step> only scheduled the function bindings depending on a step while
that step's own value was non null, so a one way function binding stopped
updating on the very transition that set its argument to null:

    <Grid Visibility="{x:Bind local:Helpers.VisibleIfNotNull(Detail), Mode=OneWay}" />

showed the element once Detail was set and then never collapsed it again, with
no binding failure and nothing in the debug output.

A null value is a legitimate argument rather than an unresolved path, and the
generated Invoke_ method already tells the two apart through TryGet_, which only
fails when an intermediate node of the argument path is null. So drop the null
check, matching what the C++/WinRT generator already emitted. FallbackValue
still applies when the path genuinely cannot be resolved, through the null check
that remains on the child steps.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover both the update that a null argument has to trigger and the instance
retrieval that keeps it safe, in C# and Visual Basic, plus two controls that
pin the shapes which must not change: path steps still skip their children when
they are null, and a function whose arguments all sit under its instance still
calls straight through the path.

TestHelper.GenerateCodeBehind now parses the bind universes the way
CompileXamlInternal does, since without it an x:Bind has no path steps and no
binding code is generated at all.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Sergio0694
Sergio Pedri (Sergio0694) requested a review from a team as a code owner August 31, 2026 14:47
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the needs-triage Issue needs to be triaged by the area owners label Aug 31, 2026
@niels9001

Copy link
Copy Markdown
Collaborator
image

Noice 😁

@Sergio0694

Copy link
Copy Markdown
Member Author
IllDoItMyselfGIF

@evelynwu-msft

Copy link
Copy Markdown
Member

Copilot found a regression in your change.

Min repro:

<DataTemplate x:DataType="local:ObservableModel">
    <Grid>
        <local:Formatter x:Name="formatter" />
        <TextBlock Text="{x:Bind formatter.Format(Child), Mode=OneWay}" />
    </Grid>
</DataTemplate>

Output when building:

\PR11732-RegressionRepro\obj\x64\Debug\net8.0-windows10.0.22621.0\win-x64\MainWindow.g.cs(215,23,215,27): error CS0029: Cannot implicitly convert type 'WinUIRepro.MainWindow.MainWindow_obj4_Bindings' to 'WinUIRepro.ObservableModel'
\PR11732-RegressionRepro\obj\x64\Debug\net8.0-windows10.0.22621.0\win-x64\MainWindow.g.cs(224,27,224,35): error CS0103: The name 'bindings' does not exist in the current context

Full repro project (includes other scenarios that Copilot tested): PR11732-RegressionRepro.zip

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

Labels

needs-triage Issue needs to be triaged by the area owners

Projects

None yet

Development

Successfully merging this pull request may close these issues.

{x:Bind} function to bool? property doesn't update on null

3 participants