The blog of dlaa.me

We've secretly changed this control's DataContext - let's see if it notices! [Workaround for a Silverlight data binding bug affecting various scenarios - including DataGrid+ContextMenu]

I was contacted by Simon Weaver via Twitter about a problem where the Bindings for MenuItems of a ContextMenu on a DataGrid were acting as though they were associated with a different row than they really were. This seemed pretty weird until Yifung Lin, one of the original DataGrid developers, suggested this might be due to the row recycling behavior DataGrid uses to improve performance. It turns out he was right - but the rabbit hole goes much deeper than that...

Sample application showing bug

Working from an internal reproduction of the problem Brian Braeckel created, I found that I was able to simplify the scenario significantly - to the point of removing ContextMenu and DataGrid entirely! Signs were pointing strongly to this being a bug in the Silverlight framework, but before I started crying wolf, I wanted someone to review my work to be sure I hadn't over-simplified things. Fortunately, RJ Boeke was around and not only confirmed the validity of my repro, but also pointed out further simplifications!

 

When all is said and done, the bug is pretty easy to describe:

If a Binding points to an element via its ElementName or Source parameters and the DataContext of a parent of that element changes, that change will NOT be propagated through the Binding. However, if the DataContext of the element itself changes, the change will be propagated correctly.

RJ and I were both kind of surprised to find this bug in Silverlight 4, but when Sage LaTorra looked into this from the QA side, he reported the same problem with Silverlight 3. Which - in a weird kind of way - is actually good news because it means things haven't gotten worse with Silverlight 4 and existing applications won't break unexpectedly!

Unfortunately, the DataGrid+ContextMenu scenario relies on this exact behavior to work correctly... When a ContextMenu is about to be displayed, a Popup is created, the menu UI is added to it, and it's shown. Because the Popup isn't in the visual tree, the ContextMenu won't see the right DataContext by default - so it sets up a Binding with its Source set to the ContextMenu's owner element. This works well and the DataContext then "flows" into the Popup where MenuItems can see it and bind to it successfully. The problem in the DataGrid scenario arises when DataGrid decides to recycle one of its rows by swapping one DataContext for another. Though this is a perfectly legitimate thing to do, it runs afoul of this bug and breaks the scenario.

 

Fortunately, there's an easy workaround. Even better: it works for all known instances of the problem, not just the DataGrid+ContextMenu kind! Here's an example of code that demonstrates the bug when the DataContext of a parent element is changed (which you can do in the sample application by clicking on the red or green background):

<!-- Simple example of the broken scenario -->
<Grid x:Name="SimpleBroken" Grid.Column="0" Grid.RowSpan="2" Background="Red">
    <TextBlock Text="{Binding DataContext, ElementName=SimpleBroken}"/>
</Grid>

And here's what it looks like with the workaround applied:

<!-- Simple example of the workaround -->
<delay:DataContextPropagationGrid x:Name="SimpleWorking" Grid.Column="1" Grid.RowSpan="2" Background="Green">
    <TextBlock Text="{Binding DataContext, ElementName=SimpleWorking}"/>
</delay:DataContextPropagationGrid>

Pretty similar, huh? The way this works is that the DataContextPropagationGrid class derives from Grid, listens for DataContext changes in the usual manner, then uses the fact that changes to the local value of DataContext work to "re-broadcast" the change to any Bindings targeting it via ElementName or Source. The important thing to note is that any Binding broken because of the underlying platform bug needs to be pointed at the DataContextPropagationGrid wrapper instead - and should then behave correctly.

Aside: I haven't historically considered Grid for workaround scenarios like this. However, it seems like a good fit: Grid can be wrapped around nearly anything without side-effects, it allows multiple children for scenarios where that might be necessary, it's familiar to developers and designers, and it keeps the workaround code simple!

 

Just to "close the loop", here's an example of a DataGrid+ContextMenu demonstrating the problem:

DataGrid+ContextMenu showing bug

To reproduce it yourself, run the sample application, right-click on every row of the red DataGrid (everything will be correct), then scroll to the end of the list and do the same - you'll quickly find a mis-match like I show above. The fix is as easy as introducing the DataContextPropagationGrid class (as I did for the green DataGrid) - the important thing is to be sure to attach the ContextMenu to the DataContextPropagationGrid so the workaround has a chance to do its thing:

<!-- DataGrid+ContextMenu example of the workaround -->
<sdk:DataGrid Grid.Column="1" Grid.Row="1" AutoGenerateColumns="False" Margin="10">
    <sdk:DataGrid.Columns>
        <sdk:DataGridTemplateColumn Header="Values">
            <sdk:DataGridTemplateColumn.CellTemplate>
                <DataTemplate>
                    <delay:DataContextPropagationGrid>
                        <toolkit:ContextMenuService.ContextMenu>
                            <toolkit:ContextMenu>
                                <toolkit:MenuItem Header="{Binding}"/>
                            </toolkit:ContextMenu>
                        </toolkit:ContextMenuService.ContextMenu>
                        <TextBlock Text="{Binding}"/>
                    </delay:DataContextPropagationGrid>
                </DataTemplate>
            </sdk:DataGridTemplateColumn.CellTemplate>
        </sdk:DataGridTemplateColumn>
    </sdk:DataGrid.Columns>
</sdk:DataGrid>

 

If you run into this Silverlight bug in the DataGrid+ContextMenu scenario, please apply the DataContextPropagationGrid workaround and things should work properly. And if you happen run into the bug in some other scenario, the good news is that the DataContextPropagationGrid workaround should work there, too! Just be mindful to point the Binding's ElementName or Source at the DataContextPropagationGrid element, and you're good to go.

 

[Click here to download the complete source code for the SilverlightDataContextBugWorkaround sample.]

 

PS - Here's the code for the workaround:

using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace Delay
{
    /// <summary>
    /// Class to help work around a Silverlight bug where DataContext changes to
    /// an element aren't propagated through Bindings on child elements that use
    /// ElementName or Source.
    /// </summary>
    public class DataContextPropagationGrid : Grid
    {
        /// <summary>
        /// Initializes a new instance of the DataContextPropagationGrid class.
        /// </summary>
        public DataContextPropagationGrid()
        {
            // Create a Binding to keep InheritedDataContextProperty correct
            SetBinding(InheritedDataContextProperty, new Binding());
        }

        /// <summary>
        /// Identifies the InheritedDataContext DependencyProperty.
        /// </summary>
        public static readonly DependencyProperty InheritedDataContextProperty =
            DependencyProperty.Register(
                "InheritedDataContext",
                typeof(object),
                typeof(DataContextPropagationGrid),
                new PropertyMetadata(null, OnInheritedDataContextChanged));

        /// <summary>
        /// Handles changes to the InheritedDataContext DependencyProperty.
        /// </summary>
        /// <param name="d">Instance with property change.</param>
        /// <param name="e">Property change details.</param>
        private static void OnInheritedDataContextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            DataContextPropagationGrid workaround = (DataContextPropagationGrid)d;
            // Update local value of DataContext to prompt Silverlight to update problematic Bindings
            workaround.DataContext = e.NewValue;
            // Unset local value of DataContext so it will continue to inherit from the parent
            workaround.ClearValue(FrameworkElement.DataContextProperty);
        }
    }
}