Skip to content

PageHeader mutates the scoped PageLayout off the Blazor renderer's synchronization context #26075

Description

@BR-RedEnzian

Is there an existing issue for this?

  • I have searched the existing issues

Description

Environment

  • ABP 10.6.0 (verified against dev source and the shipped 10.6.0 assembly)
  • Blazor Web App, @rendermode="InteractiveAuto"
  • Autofac (Volo.Abp.Autofac 10.6.0)
  • .NET SDK 10.0.400

Summary

Volo.Abp.AspNetCore.Components.Web.Theming is compiled with ConfigureAwait.Fody configured as
ContinueOnCapturedContext="false". Every await in the assembly — including in Blazor component
lifecycle methods — is therefore rewritten to ConfigureAwait(false).

As a result, PageHeader.OnParametersSetAsync resumes off the Blazor renderer's synchronization
context
and then mutates PageLayout.ToolbarItems, an ObservableCollection<T> that other
components subscribe to. Those subscribers' CollectionChanged / PropertyChanged handlers are
consequently invoked on a thread-pool thread rather than on the renderer's dispatcher.

This is a Blazor threading-contract violation: state that triggers renders in other components is
being changed off-dispatcher.

Evidence

framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/FodyWeavers.xml:

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

Confirmed in the shipped 10.6.0 assembly: Volo.Abp.AspNetCore.Components.Web.Theming.dll
contains ConfiguredTaskAwaitable references, i.e. the weaving took effect. There is no explicit
ConfigureAwait(false) in the source — it is applied at the IL level.

framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Layout/PageHeader.razor.cs:

protected async override Task OnParametersSetAsync()
{
    await base.OnParametersSetAsync();
    if (Toolbar != null)
    {
        var toolbarItems = await PageToolbarManager.GetItemsAsync(Toolbar);   // woven ConfigureAwait(false)

        if (!ShouldRenderToolbarItems(toolbarItems))
        {
            return;
        }

        ToolbarItemRenders.Clear();

        if (!Options.Value.RenderToolbar)
        {
            PageLayout.ToolbarItems.Clear();          // <-- off-dispatcher, raises CollectionChanged
            foreach (var item in toolbarItems)
            {
                PageLayout.ToolbarItems.Add(item);    // <-- off-dispatcher, raises CollectionChanged
            }
            return;
        }
        ...
    }
}

framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Layout/PageLayout.cs confirms the
observable contract and the scoped lifetime:

public class PageLayout : IScopedDependency, INotifyPropertyChanged
{
    public virtual ObservableCollection<BreadcrumbItem> BreadcrumbItems { get; } = new();
    public virtual ObservableCollection<PageToolbarItem> ToolbarItems { get; } = new();
    public event PropertyChangedEventHandler? PropertyChanged;

    public void Reset()
    {
        Title = string.Empty;        // raises PropertyChanged
        MenuItemName = string.Empty; // raises PropertyChanged
        BreadcrumbItems.Clear();     // raises CollectionChanged
        ToolbarItems.Clear();        // raises CollectionChanged
    }
}

Interaction with Dispose (context, not a request to revert)

public void Dispose()
{
    PageLayout.Reset();
    ToolbarItemRenders.Clear();
}

PageHeader.Dispose() calls PageLayout.Reset(), which raises PropertyChanged and two
CollectionChanged events. When the component is disposed because the scope itself is being
torn down, those notifications reach subscribers whose service scope is already (or is about to
be) disposed.

To be clear: this behaviour is deliberate and I am not asking for it to be reverted — see
#21185 / #21888 (clear breadcrumbs and toolbar items on dispose) and #22725 (clear title on
dispose). I mention it only because it is a second path by which PageLayout notifications are
delivered at a point where subscribers may already be gone, and any fix for the off-dispatcher
issue above should keep this path in mind.

Note also the author's own caveat on #21888: "This may cause other issues with multiple
PageHeader component usage in a same page."

Impact

Any component subscribing to PageLayout receives notifications on a thread-pool thread and,
via Dispose, during scope teardown. Whether this is survivable depends entirely on how
defensively the subscriber is written.

The LeptonX theme's ContentToolbar and Breadcrumbs subscribe with anonymous async void
handlers and never unsubscribe. In a Blazor Web App using InteractiveAuto this combination
terminates the host process with an unhandled ObjectDisposedException raised on a
thread-pool thread:

Unhandled exception. System.ObjectDisposedException: Instances cannot be resolved and nested
lifetimes cannot be created from this LifetimeScope as it (or one of its parent scopes) has
already been disposed.
   at Autofac.Extensions.DependencyInjection.AutofacServiceProvider.GetService(Type serviceType)
   at Blazorise.ComponentActivator.CreateInstance(Type componentType)
   ...
   at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
   at Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext.<InvokeAsync>g__Execute|8_0(...)
   at Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common.ContentToolbar.<obfuscated>(Object s, PropertyChangedEventArgs)
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()

The LeptonX side of this is being reported separately through commercial support, since that
package is not in this repository. This issue is about the framework side: the off-dispatcher
mutation that puts the subscriber on a thread-pool thread in the first place.

Prior related issues

I searched the tracker before filing; I could not find this reported. For reviewers, the
adjacent history is:

None of these concern the synchronization context the mutation happens on, which is what this
issue is about. A search for ContentToolbar in this repository returns no results, and I found
no issue describing off-dispatcher PageLayout mutation or the effect of the assembly-wide
ConfigureAwait(false) weaving on Blazor component lifecycle methods.

Suggested fix

Either of:

  1. Exclude Blazor component types (or the whole component assembly) from the blanket
    ConfigureAwait(false) weaving — component lifecycle methods should resume on the dispatcher.
    ConfigureAwait.Fody supports opting out per class/method with [ConfigureAwait(true)].
  2. Or apply the PageLayout mutations through InvokeAsync(...) so they are marshalled back onto
    the dispatcher regardless of how the preceding await resumed.

Option 1 is the more general fix; a blanket ConfigureAwait(false) across an assembly containing
ComponentBase subclasses is risky beyond this specific case.

Whichever route is taken, please consider the Dispose path described above as well: it delivers
the same notifications at teardown time, and it is intentional behaviour that should be preserved.

Reproduction Steps

No response

Expected behavior

PageHeader should mutate PageLayout on the renderer's synchronization context, so that
subscribers' change notifications run on the dispatcher like any other Blazor state change.

Actual behavior

No response

Regression?

No response

Known Workarounds

No response

Version

10.6.0

User Interface

Blazor Server

Database Provider

EF Core (Default)

Tiered or separate authentication server

None (Default)

Operation System

Windows (Default)

Other information

I ran into a problem within our application and Claude found two problems regarding LeptonX (reported on support page: https://abp.io/support/questions/10861/LeptonX-ContentToolbarBreadcrumbs-never-unsubscribe-from-PageLayout--async-void-handler-terminates-the-host-process-ObjectDisposedException) and the PageHeader/PageLayout components.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions