From 7b47c46e0ffa40a7749fde09da5653aad6193882 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:23:57 +0200 Subject: [PATCH 1/3] Fix Blazor per-page activity links Transfer the SSR route activity context through protected persistent component state so interactive server events retain their route link across renderer boundaries and enhanced navigation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/ComponentsActivitySource.cs | 29 +++++ .../ComponentStatePersistenceManager.cs | 20 +++ .../Components/src/RenderTree/Renderer.cs | 6 +- .../test/ComponentsActivitySourceTest.cs | 36 ++++-- .../ComponentStatePersistenceManagerTest.cs | 114 ++++++++++++++++++ .../src/RazorComponentEndpointInvoker.cs | 31 +++++ .../test/RazorComponentEndpointInvokerTest.cs | 22 ++++ .../Components/ComponentsActivityLinkStore.cs | 72 +++++++++++ 8 files changed, 321 insertions(+), 9 deletions(-) diff --git a/src/Components/Components/src/ComponentsActivitySource.cs b/src/Components/Components/src/ComponentsActivitySource.cs index a947e6b65841..5e71309b610b 100644 --- a/src/Components/Components/src/ComponentsActivitySource.cs +++ b/src/Components/Components/src/ComponentsActivitySource.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; namespace Microsoft.AspNetCore.Components; @@ -29,6 +30,34 @@ public void Init(ComponentsActivityLinkStore store) _componentsActivityLinkStore = store; } + internal void RestoreRouteActivityContext(IDictionary state) + { + if (_componentsActivityLinkStore is null) + { + return; + } + + if (!state.TryGetValue(ComponentsActivityLinkStore.PersistentRouteStateKey, out var data)) + { + _componentsActivityLinkStore.RemoveActivityContext(ComponentsActivityLinkStore.Route); + return; + } + + state.Remove(ComponentsActivityLinkStore.PersistentRouteStateKey); + var persistentState = JsonSerializer.Deserialize( + data, + ComponentsActivityPersistentStateJsonContext.Default.ComponentsActivityPersistentState); + + if (persistentState is not null) + { + _componentsActivityLinkStore.RestorePersistentRouteState(persistentState); + } + else + { + _componentsActivityLinkStore.RemoveActivityContext(ComponentsActivityLinkStore.Route); + } + } + public ComponentsActivityHandle StartNavigateActivity(string componentType, string route) { var activity = ActivitySource.CreateActivity(OnRouteName, ActivityKind.Internal, parentId: null, null, null); diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index e0f82daf4b57..65fa05cccc4e 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -19,6 +19,8 @@ public class ComponentStatePersistenceManager private bool _stateIsInitialized; private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); + private ComponentsActivitySource? _componentsActivitySource; + private IDictionary? _pendingComponentActivityState; /// /// Initializes a new instance of . @@ -51,6 +53,16 @@ public ComponentStatePersistenceManager(ILogger public PersistentComponentState State { get; } + internal void SetComponentsActivitySource(ComponentsActivitySource componentsActivitySource) + { + _componentsActivitySource = componentsActivitySource; + if (_pendingComponentActivityState is not null) + { + _componentsActivitySource.RestoreRouteActivityContext(_pendingComponentActivityState); + _pendingComponentActivityState = null; + } + } + /// /// Restores the component application state from the given . /// @@ -70,6 +82,14 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) public async Task RestoreStateAsync(IPersistentComponentStateStore store, RestoreContext context) { var data = await store.GetPersistedStateAsync(); + if (_componentsActivitySource is not null) + { + _componentsActivitySource.RestoreRouteActivityContext(data); + } + else + { + _pendingComponentActivityState = data; + } if (_stateIsInitialized) { diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 015879de2a8d..c86c7a172c02 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -112,7 +112,11 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, if (ComponentsActivitySource.IsSupported) { _componentsActivitySource = serviceProvider.GetService(); - _componentsActivitySource?.Init(new ComponentsActivityLinkStore(this)); + if (_componentsActivitySource is not null) + { + _componentsActivitySource.Init(new ComponentsActivityLinkStore(this)); + serviceProvider.GetService()?.SetComponentsActivitySource(_componentsActivitySource); + } } ServiceProviderCascadingValueSuppliers = serviceProvider.GetService() is null diff --git a/src/Components/Components/test/ComponentsActivitySourceTest.cs b/src/Components/Components/test/ComponentsActivitySourceTest.cs index cccb3fb79170..93434b832246 100644 --- a/src/Components/Components/test/ComponentsActivitySourceTest.cs +++ b/src/Components/Components/test/ComponentsActivitySourceTest.cs @@ -71,9 +71,8 @@ public void StartRouteActivity_CreatesAndStartsActivity() } [Fact] - public void StartEventActivity_CreatesAndStartsActivity() + public void StartEventActivity_WithSharedLinkStore_LinksToRoute() { - // Arrange var componentsActivitySource = new ComponentsActivitySource(); var linkstore = new ComponentsActivityLinkStore(null); componentsActivitySource.Init(linkstore); @@ -81,15 +80,13 @@ public void StartEventActivity_CreatesAndStartsActivity() var methodName = "OnClick"; var attributeName = "onclick"; - // First set up a circuit and route context linkstore.SetActivityContext(ComponentsActivityLinkStore.Circuit, default, new KeyValuePair("aspnetcore.components.circuit.id", "test-circuit-id")); - componentsActivitySource.StartNavigateActivity("ParentComponent", "/parent"); + var routeActivityHandle = componentsActivitySource.StartNavigateActivity("ParentComponent", "/parent"); + componentsActivitySource.StopNavigateActivity(routeActivityHandle, null); - // Act var activityHandle = ComponentsActivitySource.StartHandleEventActivity(componentType, methodName, attributeName); var activity = activityHandle.Activity; - // Assert Assert.NotNull(activity); Assert.Equal(ComponentsActivitySource.OnEventName, activity.OperationName); Assert.Equal($"Event {attributeName} -> {componentType}.{methodName}", activity.DisplayName); @@ -100,10 +97,33 @@ public void StartEventActivity_CreatesAndStartsActivity() Assert.Equal(attributeName, activity.GetTagItem("aspnetcore.components.attribute.name")); Assert.False(activity.IsStopped); - componentsActivitySource.StopNavigateActivity(activityHandle, null); + componentsActivitySource.StopHandleEventActivity(activityHandle, null); Assert.True(activity.IsStopped); Assert.Equal("test-circuit-id", activity.GetTagItem("aspnetcore.components.circuit.id")); - Assert.Empty(activity.Links); + Assert.Collection(activity.Links, link => Assert.Equal(routeActivityHandle.Activity.Context, link.Context)); + } + + [Fact] + public void StartEventActivity_WithSeparateLinkStores_DoesNotLinkToRoute() + { + var endpointActivitySource = new ComponentsActivitySource(); + var endpointLinkStore = new ComponentsActivityLinkStore(null); + endpointActivitySource.Init(endpointLinkStore); + + var routeActivityHandle = endpointActivitySource.StartNavigateActivity("ParentComponent", "/parent"); + endpointActivitySource.StopNavigateActivity(routeActivityHandle, null); + Assert.NotNull(routeActivityHandle.Activity); + + var circuitActivitySource = new ComponentsActivitySource(); + var circuitLinkStore = new ComponentsActivityLinkStore(null); + circuitActivitySource.Init(circuitLinkStore); + + var eventActivityHandle = ComponentsActivitySource.StartHandleEventActivity("TestComponent", "OnClick", "onclick"); + circuitActivitySource.StopHandleEventActivity(eventActivityHandle, null); + Assert.NotNull(eventActivityHandle.Activity); + + Assert.Empty(eventActivityHandle.Activity.Links); + Assert.DoesNotContain(eventActivityHandle.Activity.Links, link => link.Context == routeActivityHandle.Activity.Context); } [Fact] diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 34046eaf2342..a71e52ad0408 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Collections; +using System.Diagnostics; using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; @@ -78,6 +79,119 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() await Assert.ThrowsAsync(() => persistenceManager.RestoreStateAsync(store)); } + [Fact] + public async Task RestoreStateAsync_TransfersRouteActivityContext() + { + var routeContext = CreateActivityContext(); + var interactiveLinkStore = new ComponentsActivityLinkStore(null); + var interactiveActivitySource = new ComponentsActivitySource(); + interactiveActivitySource.Init(interactiveLinkStore); + var interactivePersistenceManager = CreatePersistenceManager(interactiveActivitySource); + + var persistedState = CreatePersistedRouteState(routeContext, "/counter"); + await interactivePersistenceManager.RestoreStateAsync(persistedState); + + Assert.Empty(persistedState.State); + Assert.True(interactiveLinkStore.TryGetActivityContext( + ComponentsActivityLinkStore.Route, + out var restoredContext, + out var restoredTag)); + Assert.Equal(routeContext, restoredContext); + Assert.Equal(new KeyValuePair("aspnetcore.components.route", "/counter"), restoredTag); + } + + [Fact] + public async Task RestoreStateAsync_TransfersRouteActivityContextWhenActivitySourceIsSetAfterRestore() + { + var routeContext = CreateActivityContext(); + var persistedState = CreatePersistedRouteState(routeContext, "/counter"); + var interactivePersistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + + await interactivePersistenceManager.RestoreStateAsync(persistedState); + + var interactiveLinkStore = new ComponentsActivityLinkStore(null); + var interactiveActivitySource = new ComponentsActivitySource(); + interactiveActivitySource.Init(interactiveLinkStore); + interactivePersistenceManager.SetComponentsActivitySource(interactiveActivitySource); + + Assert.Empty(persistedState.State); + Assert.True(interactiveLinkStore.TryGetActivityContext( + ComponentsActivityLinkStore.Route, + out var restoredContext, + out var restoredTag)); + Assert.Equal(routeContext, restoredContext); + Assert.Equal(new KeyValuePair("aspnetcore.components.route", "/counter"), restoredTag); + } + + [Fact] + public async Task RestoreStateAsync_ValueUpdateReplacesAndClearsRouteActivityContext() + { + var initialContext = CreateActivityContext(); + var updatedContext = CreateActivityContext(); + var interactiveLinkStore = new ComponentsActivityLinkStore(null); + var interactiveActivitySource = new ComponentsActivitySource(); + interactiveActivitySource.Init(interactiveLinkStore); + var interactivePersistenceManager = CreatePersistenceManager(interactiveActivitySource); + + await interactivePersistenceManager.RestoreStateAsync( + CreatePersistedRouteState(initialContext, "/initial")); + await interactivePersistenceManager.RestoreStateAsync( + CreatePersistedRouteState(updatedContext, "/updated"), + RestoreContext.ValueUpdate); + + Assert.True(interactiveLinkStore.TryGetActivityContext( + ComponentsActivityLinkStore.Route, + out var restoredContext, + out var restoredTag)); + Assert.Equal(updatedContext, restoredContext); + Assert.Equal(new KeyValuePair("aspnetcore.components.route", "/updated"), restoredTag); + + await interactivePersistenceManager.RestoreStateAsync(new TestStore([]), RestoreContext.ValueUpdate); + + Assert.False(interactiveLinkStore.TryGetActivityContext( + ComponentsActivityLinkStore.Route, + out _, + out _)); + } + + private static TestStore CreatePersistedRouteState(ActivityContext context, string route) + { + var linkStore = new ComponentsActivityLinkStore(null); + linkStore.SetActivityContext( + ComponentsActivityLinkStore.Route, + context, + new KeyValuePair("aspnetcore.components.route", route)); + + Assert.True(linkStore.TryCreatePersistentRouteState(out var persistentState)); + var state = JsonSerializer.SerializeToUtf8Bytes( + persistentState, + ComponentsActivityPersistentStateJsonContext.Default.ComponentsActivityPersistentState); + + return new TestStore(new Dictionary + { + [ComponentsActivityLinkStore.PersistentRouteStateKey] = state + }); + } + + private static ComponentStatePersistenceManager CreatePersistenceManager(ComponentsActivitySource activitySource) + { + var manager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + manager.SetComponentsActivitySource(activitySource); + return manager; + } + + private static ActivityContext CreateActivityContext() + => new( + ActivityTraceId.CreateRandom(), + ActivitySpanId.CreateRandom(), + ActivityTraceFlags.Recorded, + traceState: "vendor=value", + isRemote: false); + private IServiceProvider CreateServiceProvider() => new ServiceCollection().BuildServiceProvider(); diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 58d8a4e3090d..0557326017a2 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Antiforgery; @@ -10,6 +11,7 @@ using Microsoft.AspNetCore.Components.Endpoints.Rendering; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -25,6 +27,7 @@ internal partial class RazorComponentEndpointInvoker : IRazorComponentEndpointIn private readonly EndpointHtmlRenderer _renderer; private readonly ILogger _logger; private readonly ComponentsActivityLinkStore _activityLinkStore; + private bool _activityStatePersistenceRegistered; public RazorComponentEndpointInvoker(EndpointHtmlRenderer renderer, ILogger logger) { @@ -78,6 +81,8 @@ private async Task RenderComponentCore(HttpContext context) _activityLinkStore.SetActivityContext(ComponentsActivityLinkStore.Http, httpActivityContext, null); } + RegisterActivityStatePersistence(context); + await _renderer.InitializeStandardComponentServicesAsync( context, componentType: pageComponent, @@ -205,6 +210,32 @@ await _renderer.InitializeStandardComponentServicesAsync( await bufferWriter.FlushAsync(); } + internal void RegisterActivityStatePersistence(HttpContext context) + { + if (_activityStatePersistenceRegistered) + { + return; + } + + var state = context.RequestServices.GetRequiredService(); + state.RegisterOnPersisting(PersistActivityState, RenderMode.InteractiveServer); + _activityStatePersistenceRegistered = true; + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Razor Components endpoints do not support trimming or native AOT.")] + Task PersistActivityState() + { + if (_activityLinkStore.TryCreatePersistentRouteState(out var routeState) && routeState is not null) + { + state.PersistAsJson(ComponentsActivityLinkStore.PersistentRouteStateKey, routeState); + } + + return Task.CompletedTask; + } + } + private async Task ValidateRequestAsync(HttpContext context) { var processPost = HttpMethods.IsPost(context.Request.Method) && diff --git a/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs b/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs index 8dc20784a35f..89bd240cd008 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs +++ b/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -19,6 +21,26 @@ namespace Microsoft.AspNetCore.Components.Endpoints.Tests; public class RazorComponentEndpointInvokerTest { + [Fact] + public void ActivityStatePersistence_RegistersForInteractiveServerOnly() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddRazorComponents(); + serviceCollection.AddSingleton(new ConfigurationBuilder().Build()); + serviceCollection.AddSingleton(new TestWebHostEnvironment()); + var services = serviceCollection.BuildServiceProvider(); + var renderer = new EndpointHtmlRenderer(services, NullLoggerFactory.Instance); + var invoker = new RazorComponentEndpointInvoker( + renderer, + NullLogger.Instance); + var context = new DefaultHttpContext { RequestServices = services }; + + invoker.RegisterActivityStatePersistence(context); + + var registration = Assert.Single(services.GetRequiredService().RegisteredCallbacks); + Assert.IsType(registration.RenderMode); + } + [Fact] public async Task Invoker_RejectsPostRequestsWithNonFormDataContentTypesAsync() { diff --git a/src/Shared/Components/ComponentsActivityLinkStore.cs b/src/Shared/Components/ComponentsActivityLinkStore.cs index 7ada7041a949..a8c882b0d260 100644 --- a/src/Shared/Components/ComponentsActivityLinkStore.cs +++ b/src/Shared/Components/ComponentsActivityLinkStore.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Components.RenderTree; // this internal helper class is used in both Components and Components.Server projects as a different type @@ -25,6 +26,7 @@ internal class ComponentsActivityLinkStore public const string Route = "Route"; public const string Circuit = "Circuit"; public const string Event = "Event"; + public const string PersistentRouteStateKey = "__aspnetcore.components.route.activity"; private readonly Dictionary _store; @@ -41,6 +43,64 @@ public void SetActivityContext(string category, ActivityContext activityLink, Ke _store[category] = new CategoryLink(activityLink, tag); } + public bool TryGetActivityContext(string category, out ActivityContext activityLink, out KeyValuePair? tag) + { + if (_store.TryGetValue(category, out var link)) + { + activityLink = link.Item1; + tag = link.Item2; + return true; + } + + activityLink = default; + tag = default; + return false; + } + + public void RemoveActivityContext(string category) + { + _store.Remove(category); + } + + public bool TryCreatePersistentRouteState(out ComponentsActivityPersistentState? state) + { + if (TryGetActivityContext(Route, out var context, out var tag) && + context != default && + tag is { } routeTag && + routeTag.Key == "aspnetcore.components.route" && + routeTag.Value is string route) + { + state = new ComponentsActivityPersistentState( + $"00-{context.TraceId}-{context.SpanId}-{(byte)context.TraceFlags:x2}", + context.TraceState, + context.IsRemote, + route); + return true; + } + + state = null; + return false; + } + + public void RestorePersistentRouteState(ComponentsActivityPersistentState state) + { + if (ActivityContext.TryParse( + state.TraceParent, + state.TraceState, + state.IsRemote, + out var context)) + { + SetActivityContext( + Route, + context, + new KeyValuePair("aspnetcore.components.route", state.Route)); + } + else + { + RemoveActivityContext(Route); + } + } + public void AddActivityContexts(string exceptCategory, Activity targetActivity) { foreach (var kvp in _store) @@ -64,3 +124,15 @@ public void AddActivityContexts(string exceptCategory, Activity targetActivity) [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_ActivityLinksStore")] static extern object GetActivityLinksStore(Renderer instance); } + +internal sealed record ComponentsActivityPersistentState( + string TraceParent, + string? TraceState, + bool IsRemote, + string Route); + +[JsonSerializable(typeof(ComponentsActivityPersistentState))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] +internal sealed partial class ComponentsActivityPersistentStateJsonContext : JsonSerializerContext; From 5bb1ae99fa724759aa0b96e1a5106f71d6ad529c Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:00:55 +0200 Subject: [PATCH 2/3] Use persistent service for Blazor activity links Move route activity state ownership into a scoped persistent service that applies initial and enhanced-navigation updates to the renderer link store. Add end-to-end coverage for circuit startup and navigation without an interactive router. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/ComponentsActivitySource.cs | 29 ----- .../Components/src/ComponentsActivityState.cs | 56 +++++++++ .../ComponentStatePersistenceManager.cs | 20 --- ...eringMetricsServiceCollectionExtensions.cs | 23 ++++ .../Components/src/RenderTree/Renderer.cs | 5 +- .../test/ComponentsActivityStateTest.cs | 81 +++++++++++++ .../ComponentStatePersistenceManagerTest.cs | 114 ------------------ .../src/RazorComponentEndpointInvoker.cs | 31 ----- .../test/RazorComponentEndpointInvokerTest.cs | 24 ++-- .../ServerRazorComponentsBuilderExtensions.cs | 4 + .../ServerRenderingTests/InteractivityTest.cs | 37 ++++++ .../RazorComponentEndpointsStartup.cs | 5 + .../Components/ActivityLinksProbe.razor | 46 +++++++ .../ComponentsActivityTestListener.cs | 32 +++++ .../ActivityLinksCircuitInitialization.razor | 5 + .../Interactivity/ActivityLinksLayout.razor | 5 + .../ActivityLinksNavigationOne.razor | 6 + .../ActivityLinksNavigationTwo.razor | 4 + .../Components/ComponentsActivityLinkStore.cs | 8 +- 19 files changed, 318 insertions(+), 217 deletions(-) create mode 100644 src/Components/Components/src/ComponentsActivityState.cs create mode 100644 src/Components/Components/test/ComponentsActivityStateTest.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor diff --git a/src/Components/Components/src/ComponentsActivitySource.cs b/src/Components/Components/src/ComponentsActivitySource.cs index 5e71309b610b..a947e6b65841 100644 --- a/src/Components/Components/src/ComponentsActivitySource.cs +++ b/src/Components/Components/src/ComponentsActivitySource.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; namespace Microsoft.AspNetCore.Components; @@ -30,34 +29,6 @@ public void Init(ComponentsActivityLinkStore store) _componentsActivityLinkStore = store; } - internal void RestoreRouteActivityContext(IDictionary state) - { - if (_componentsActivityLinkStore is null) - { - return; - } - - if (!state.TryGetValue(ComponentsActivityLinkStore.PersistentRouteStateKey, out var data)) - { - _componentsActivityLinkStore.RemoveActivityContext(ComponentsActivityLinkStore.Route); - return; - } - - state.Remove(ComponentsActivityLinkStore.PersistentRouteStateKey); - var persistentState = JsonSerializer.Deserialize( - data, - ComponentsActivityPersistentStateJsonContext.Default.ComponentsActivityPersistentState); - - if (persistentState is not null) - { - _componentsActivityLinkStore.RestorePersistentRouteState(persistentState); - } - else - { - _componentsActivityLinkStore.RemoveActivityContext(ComponentsActivityLinkStore.Route); - } - } - public ComponentsActivityHandle StartNavigateActivity(string componentType, string route) { var activity = ActivitySource.CreateActivity(OnRouteName, ActivityKind.Internal, parentId: null, null, null); diff --git a/src/Components/Components/src/ComponentsActivityState.cs b/src/Components/Components/src/ComponentsActivityState.cs new file mode 100644 index 000000000000..4aaba2b50a0f --- /dev/null +++ b/src/Components/Components/src/ComponentsActivityState.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Infrastructure; + +namespace Microsoft.AspNetCore.Components; + +internal sealed class ComponentsActivityState +{ + private ComponentsActivityLinkStore? _activityLinkStore; + private ComponentsActivityPersistentStateUpdate? _pendingState; + private bool _hasPendingState; + + [PersistentState(AllowUpdates = true)] + public ComponentsActivityPersistentStateUpdate ActivityState + { + get + { + ComponentsActivityPersistentState? routeState = null; + _activityLinkStore?.TryCreatePersistentRouteState(out routeState); + return new ComponentsActivityPersistentStateUpdate(routeState); + } + set + { + _pendingState = value; + _hasPendingState = true; + ApplyPendingState(); + } + } + + public void Initialize(ComponentsActivityLinkStore activityLinkStore) + { + _activityLinkStore = activityLinkStore; + ApplyPendingState(); + } + + private void ApplyPendingState() + { + if (_activityLinkStore is null || !_hasPendingState) + { + return; + } + + if (_pendingState?.Route is { } routeState) + { + _activityLinkStore.RestorePersistentRouteState(routeState); + } + else + { + _activityLinkStore.RemoveActivityContext(ComponentsActivityLinkStore.Route); + } + + _pendingState = null; + _hasPendingState = false; + } +} diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 65fa05cccc4e..e0f82daf4b57 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -19,8 +19,6 @@ public class ComponentStatePersistenceManager private bool _stateIsInitialized; private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); - private ComponentsActivitySource? _componentsActivitySource; - private IDictionary? _pendingComponentActivityState; /// /// Initializes a new instance of . @@ -53,16 +51,6 @@ public ComponentStatePersistenceManager(ILogger public PersistentComponentState State { get; } - internal void SetComponentsActivitySource(ComponentsActivitySource componentsActivitySource) - { - _componentsActivitySource = componentsActivitySource; - if (_pendingComponentActivityState is not null) - { - _componentsActivitySource.RestoreRouteActivityContext(_pendingComponentActivityState); - _pendingComponentActivityState = null; - } - } - /// /// Restores the component application state from the given . /// @@ -82,14 +70,6 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) public async Task RestoreStateAsync(IPersistentComponentStateStore store, RestoreContext context) { var data = await store.GetPersistedStateAsync(); - if (_componentsActivitySource is not null) - { - _componentsActivitySource.RestoreRouteActivityContext(data); - } - else - { - _pendingComponentActivityState = data; - } if (_stateIsInitialized) { diff --git a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs index e1e224e5f71b..e0a96da86a98 100644 --- a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs +++ b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs @@ -39,10 +39,33 @@ public static IServiceCollection AddComponentsTracing( IServiceCollection services) { services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddKeyedSingleton( + typeof(ComponentsMetricsServiceCollectionExtensions)); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); return services; } + private sealed class UnsupportedComponentsActivityStateRenderMode : IComponentRenderMode; + + private sealed class ComponentsActivityStatePersistentServiceRegistration(IServiceProvider serviceProvider) + : IPersistentServiceRegistration + { + private readonly PersistentServiceRegistration _registration = new( + serviceProvider.GetRequiredKeyedService( + typeof(ComponentsMetricsServiceCollectionExtensions))); + + public string Assembly => _registration.Assembly; + + public string FullTypeName => _registration.FullTypeName; + + public IComponentRenderMode? GetRenderModeOrDefault() => _registration.GetRenderModeOrDefault(); + + public Type? GetResolvedTypeOrNull() => _registration.GetResolvedTypeOrNull(); + } + private static bool IsMeterFactoryRegistered(IServiceCollection services) { foreach (var service in services) diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index c86c7a172c02..42ce41df8e32 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -114,8 +114,9 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, _componentsActivitySource = serviceProvider.GetService(); if (_componentsActivitySource is not null) { - _componentsActivitySource.Init(new ComponentsActivityLinkStore(this)); - serviceProvider.GetService()?.SetComponentsActivitySource(_componentsActivitySource); + var activityLinkStore = new ComponentsActivityLinkStore(this); + _componentsActivitySource.Init(activityLinkStore); + serviceProvider.GetService()?.Initialize(activityLinkStore); } } diff --git a/src/Components/Components/test/ComponentsActivityStateTest.cs b/src/Components/Components/test/ComponentsActivityStateTest.cs new file mode 100644 index 000000000000..f3f9976e816f --- /dev/null +++ b/src/Components/Components/test/ComponentsActivityStateTest.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.AspNetCore.Components.Infrastructure; + +namespace Microsoft.AspNetCore.Components; + +public class ComponentsActivityStateTest +{ + [Fact] + public void CircuitInitialization_AppliesStateRestoredBeforeRendererInitialization() + { + var routeContext = CreateActivityContext(); + var activityState = new ComponentsActivityState + { + ActivityState = CreateState(routeContext, "/counter") + }; + var linkStore = new ComponentsActivityLinkStore(null); + + activityState.Initialize(linkStore); + + AssertRoute(linkStore, routeContext, "/counter"); + } + + [Fact] + public void NavigationUpdate_ReplacesRouteWhenInteractiveTreeHasNoRouter() + { + var initialContext = CreateActivityContext(); + var updatedContext = CreateActivityContext(); + var linkStore = new ComponentsActivityLinkStore(null); + var activityState = new ComponentsActivityState(); + activityState.Initialize(linkStore); + activityState.ActivityState = CreateState(initialContext, "/initial"); + + activityState.ActivityState = CreateState(updatedContext, "/updated"); + + AssertRoute(linkStore, updatedContext, "/updated"); + } + + [Fact] + public void NavigationUpdate_ClearsRouteWhenStateHasNoRoute() + { + var linkStore = new ComponentsActivityLinkStore(null); + var activityState = new ComponentsActivityState(); + activityState.Initialize(linkStore); + activityState.ActivityState = CreateState(CreateActivityContext(), "/counter"); + + activityState.ActivityState = new ComponentsActivityPersistentStateUpdate(null); + + Assert.False(linkStore.TryGetActivityContext(ComponentsActivityLinkStore.Route, out _, out _)); + } + + private static ComponentsActivityPersistentStateUpdate CreateState(ActivityContext context, string route) + => new(new ComponentsActivityPersistentState( + $"00-{context.TraceId}-{context.SpanId}-{(byte)context.TraceFlags:x2}", + context.TraceState, + context.IsRemote, + route)); + + private static ActivityContext CreateActivityContext() + => new( + ActivityTraceId.CreateRandom(), + ActivitySpanId.CreateRandom(), + ActivityTraceFlags.Recorded, + traceState: "vendor=value", + isRemote: false); + + private static void AssertRoute( + ComponentsActivityLinkStore linkStore, + ActivityContext expectedContext, + string expectedRoute) + { + Assert.True(linkStore.TryGetActivityContext( + ComponentsActivityLinkStore.Route, + out var context, + out var tag)); + Assert.Equal(expectedContext, context); + Assert.Equal(new KeyValuePair("aspnetcore.components.route", expectedRoute), tag); + } +} diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index a71e52ad0408..34046eaf2342 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -3,7 +3,6 @@ using System.Buffers; using System.Collections; -using System.Diagnostics; using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; @@ -79,119 +78,6 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() await Assert.ThrowsAsync(() => persistenceManager.RestoreStateAsync(store)); } - [Fact] - public async Task RestoreStateAsync_TransfersRouteActivityContext() - { - var routeContext = CreateActivityContext(); - var interactiveLinkStore = new ComponentsActivityLinkStore(null); - var interactiveActivitySource = new ComponentsActivitySource(); - interactiveActivitySource.Init(interactiveLinkStore); - var interactivePersistenceManager = CreatePersistenceManager(interactiveActivitySource); - - var persistedState = CreatePersistedRouteState(routeContext, "/counter"); - await interactivePersistenceManager.RestoreStateAsync(persistedState); - - Assert.Empty(persistedState.State); - Assert.True(interactiveLinkStore.TryGetActivityContext( - ComponentsActivityLinkStore.Route, - out var restoredContext, - out var restoredTag)); - Assert.Equal(routeContext, restoredContext); - Assert.Equal(new KeyValuePair("aspnetcore.components.route", "/counter"), restoredTag); - } - - [Fact] - public async Task RestoreStateAsync_TransfersRouteActivityContextWhenActivitySourceIsSetAfterRestore() - { - var routeContext = CreateActivityContext(); - var persistedState = CreatePersistedRouteState(routeContext, "/counter"); - var interactivePersistenceManager = new ComponentStatePersistenceManager( - NullLogger.Instance, - new ServiceCollection().BuildServiceProvider()); - - await interactivePersistenceManager.RestoreStateAsync(persistedState); - - var interactiveLinkStore = new ComponentsActivityLinkStore(null); - var interactiveActivitySource = new ComponentsActivitySource(); - interactiveActivitySource.Init(interactiveLinkStore); - interactivePersistenceManager.SetComponentsActivitySource(interactiveActivitySource); - - Assert.Empty(persistedState.State); - Assert.True(interactiveLinkStore.TryGetActivityContext( - ComponentsActivityLinkStore.Route, - out var restoredContext, - out var restoredTag)); - Assert.Equal(routeContext, restoredContext); - Assert.Equal(new KeyValuePair("aspnetcore.components.route", "/counter"), restoredTag); - } - - [Fact] - public async Task RestoreStateAsync_ValueUpdateReplacesAndClearsRouteActivityContext() - { - var initialContext = CreateActivityContext(); - var updatedContext = CreateActivityContext(); - var interactiveLinkStore = new ComponentsActivityLinkStore(null); - var interactiveActivitySource = new ComponentsActivitySource(); - interactiveActivitySource.Init(interactiveLinkStore); - var interactivePersistenceManager = CreatePersistenceManager(interactiveActivitySource); - - await interactivePersistenceManager.RestoreStateAsync( - CreatePersistedRouteState(initialContext, "/initial")); - await interactivePersistenceManager.RestoreStateAsync( - CreatePersistedRouteState(updatedContext, "/updated"), - RestoreContext.ValueUpdate); - - Assert.True(interactiveLinkStore.TryGetActivityContext( - ComponentsActivityLinkStore.Route, - out var restoredContext, - out var restoredTag)); - Assert.Equal(updatedContext, restoredContext); - Assert.Equal(new KeyValuePair("aspnetcore.components.route", "/updated"), restoredTag); - - await interactivePersistenceManager.RestoreStateAsync(new TestStore([]), RestoreContext.ValueUpdate); - - Assert.False(interactiveLinkStore.TryGetActivityContext( - ComponentsActivityLinkStore.Route, - out _, - out _)); - } - - private static TestStore CreatePersistedRouteState(ActivityContext context, string route) - { - var linkStore = new ComponentsActivityLinkStore(null); - linkStore.SetActivityContext( - ComponentsActivityLinkStore.Route, - context, - new KeyValuePair("aspnetcore.components.route", route)); - - Assert.True(linkStore.TryCreatePersistentRouteState(out var persistentState)); - var state = JsonSerializer.SerializeToUtf8Bytes( - persistentState, - ComponentsActivityPersistentStateJsonContext.Default.ComponentsActivityPersistentState); - - return new TestStore(new Dictionary - { - [ComponentsActivityLinkStore.PersistentRouteStateKey] = state - }); - } - - private static ComponentStatePersistenceManager CreatePersistenceManager(ComponentsActivitySource activitySource) - { - var manager = new ComponentStatePersistenceManager( - NullLogger.Instance, - new ServiceCollection().BuildServiceProvider()); - manager.SetComponentsActivitySource(activitySource); - return manager; - } - - private static ActivityContext CreateActivityContext() - => new( - ActivityTraceId.CreateRandom(), - ActivitySpanId.CreateRandom(), - ActivityTraceFlags.Recorded, - traceState: "vendor=value", - isRemote: false); - private IServiceProvider CreateServiceProvider() => new ServiceCollection().BuildServiceProvider(); diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 0557326017a2..58d8a4e3090d 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -3,7 +3,6 @@ using System.Buffers; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Antiforgery; @@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Components.Endpoints.Rendering; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Infrastructure; -using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -27,7 +25,6 @@ internal partial class RazorComponentEndpointInvoker : IRazorComponentEndpointIn private readonly EndpointHtmlRenderer _renderer; private readonly ILogger _logger; private readonly ComponentsActivityLinkStore _activityLinkStore; - private bool _activityStatePersistenceRegistered; public RazorComponentEndpointInvoker(EndpointHtmlRenderer renderer, ILogger logger) { @@ -81,8 +78,6 @@ private async Task RenderComponentCore(HttpContext context) _activityLinkStore.SetActivityContext(ComponentsActivityLinkStore.Http, httpActivityContext, null); } - RegisterActivityStatePersistence(context); - await _renderer.InitializeStandardComponentServicesAsync( context, componentType: pageComponent, @@ -210,32 +205,6 @@ await _renderer.InitializeStandardComponentServicesAsync( await bufferWriter.FlushAsync(); } - internal void RegisterActivityStatePersistence(HttpContext context) - { - if (_activityStatePersistenceRegistered) - { - return; - } - - var state = context.RequestServices.GetRequiredService(); - state.RegisterOnPersisting(PersistActivityState, RenderMode.InteractiveServer); - _activityStatePersistenceRegistered = true; - - [UnconditionalSuppressMessage( - "Trimming", - "IL2026", - Justification = "Razor Components endpoints do not support trimming or native AOT.")] - Task PersistActivityState() - { - if (_activityLinkStore.TryCreatePersistentRouteState(out var routeState) && routeState is not null) - { - state.PersistAsJson(ComponentsActivityLinkStore.PersistentRouteStateKey, routeState); - } - - return Task.CompletedTask; - } - } - private async Task ValidateRequestAsync(HttpContext context) { var processPost = HttpMethods.IsPost(context.Request.Method) && diff --git a/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs b/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs index 89bd240cd008..0b4c33ee968d 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs +++ b/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents; using Microsoft.AspNetCore.Components.Infrastructure; -using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -22,23 +21,20 @@ namespace Microsoft.AspNetCore.Components.Endpoints.Tests; public class RazorComponentEndpointInvokerTest { [Fact] - public void ActivityStatePersistence_RegistersForInteractiveServerOnly() + public async Task ComponentsActivityState_IsNotPersistedForWebAssemblyStore() { - var serviceCollection = new ServiceCollection(); - serviceCollection.AddRazorComponents(); - serviceCollection.AddSingleton(new ConfigurationBuilder().Build()); - serviceCollection.AddSingleton(new TestWebHostEnvironment()); - var services = serviceCollection.BuildServiceProvider(); + var services = new ServiceCollection().AddRazorComponents() + .Services.AddSingleton(new ConfigurationBuilder().Build()) + .AddSingleton(new TestWebHostEnvironment()) + .BuildServiceProvider(); var renderer = new EndpointHtmlRenderer(services, NullLoggerFactory.Instance); - var invoker = new RazorComponentEndpointInvoker( - renderer, - NullLogger.Instance); - var context = new DefaultHttpContext { RequestServices = services }; + var persistenceManager = services.GetRequiredService(); + await persistenceManager.RestoreStateAsync(new PrerenderComponentApplicationStore()); + var store = new PrerenderComponentApplicationStore(); - invoker.RegisterActivityStatePersistence(context); + await persistenceManager.PersistStateAsync(store, renderer); - var registration = Assert.Single(services.GetRequiredService().RegisteredCallbacks); - Assert.IsType(registration.RenderMode); + Assert.Null(store.PersistedState); } [Fact] diff --git a/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs b/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs index 3e6e08c45fa1..8baacbc9a719 100644 --- a/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Endpoints.Infrastructure; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; @@ -35,6 +36,9 @@ public static IServerSideBlazorBuilder AddInteractiveServerComponents(this IRazo ArgumentNullException.ThrowIfNull(builder, nameof(builder)); builder.Services.AddServerSideBlazor(configure); + builder.Services.AddKeyedSingleton( + typeof(ComponentsMetricsServiceCollectionExtensions), + RenderMode.InteractiveServer); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); return new DefaultServerSideBlazorBuilder(builder.Services); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 9afc3ec712d4..dae01caabda8 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -27,6 +27,43 @@ public InteractivityTest( public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); + [Fact] + public void PerPageActivityLinks_AreRestoredWhenCircuitIsInitialized() + { + Navigate($"{ServerPathBase}/activity-links/circuit-initialization"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-probe-interactive")).Text); + Browser.Click(By.Id("activity-links-capture")); + + Browser.Equal( + "/activity-links/circuit-initialization", + () => Browser.FindElement(By.Id("activity-links-route")).Text); + Browser.Equal("2", () => Browser.FindElement(By.Id("activity-links-count")).Text); + } + + [Fact] + public void PerPageActivityLinks_AreUpdatedWhenCircuitReceivesEnhancedNavigation() + { + Navigate($"{ServerPathBase}/activity-links/navigation/one"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-probe-interactive")).Text); + var instanceId = Browser.FindElement(By.Id("activity-links-probe-instance")).Text; + + Browser.Click(By.Id("activity-links-navigate")); + + Browser.Equal( + "Activity links navigation two", + () => Browser.FindElement(By.TagName("h1")).Text); + Browser.Equal( + instanceId, + () => Browser.FindElement(By.Id("activity-links-probe-instance")).Text); + Browser.Click(By.Id("activity-links-capture")); + Browser.Equal( + "/activity-links/navigation/two", + () => Browser.FindElement(By.Id("activity-links-route")).Text); + Browser.Equal("2", () => Browser.FindElement(By.Id("activity-links-count")).Text); + } + [Fact] public void CanRenderInteractiveServerComponent() { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index f7ea724f174d..31840e9af084 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -228,6 +228,11 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen app.Use((ctx, nxt) => { + if (ctx.Request.Path.StartsWithSegments("/activity-links")) + { + ComponentsActivityTestListener.Enable(); + } + if (ctx.Request.Query.ContainsKey("add-csp")) { ctx.Response.Headers.Add("Content-Security-Policy", "script-src 'self' 'unsafe-inline'"); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor new file mode 100644 index 000000000000..e8ade6b41cd6 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor @@ -0,0 +1,46 @@ +@using System.Diagnostics + + + +@code { + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private bool _interactive; + private string? _route; + private int? _linkCount; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _interactive = true; + StateHasChanged(); + } + } + + private void CaptureActivity() + { + _ = CaptureActivityAfterEventAsync(Activity.Current); + } + + private async Task CaptureActivityAfterEventAsync(Activity? activity) + { + while (activity is { IsStopped: false }) + { + await Task.Delay(10); + } + + await InvokeAsync(() => + { + _route = activity?.GetTagItem("aspnetcore.components.route") as string; + _linkCount = activity?.Links.Count(); + ComponentsActivityTestListener.Disable(); + StateHasChanged(); + }); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs new file mode 100644 index 000000000000..0b2ed19123d9 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Components.TestServer.RazorComponents; + +internal static class ComponentsActivityTestListener +{ + private static int _enabled; + private static readonly ActivityListener _listener = new() + { + ShouldListenTo = source => source.Name is "Microsoft.AspNetCore.Components" or "Microsoft.AspNetCore.Components.Server.Circuits", + Sample = (ref ActivityCreationOptions _) => + Volatile.Read(ref _enabled) == 1 ? ActivitySamplingResult.AllData : ActivitySamplingResult.None, + }; + + static ComponentsActivityTestListener() + { + ActivitySource.AddActivityListener(_listener); + } + + public static void Enable() + { + Volatile.Write(ref _enabled, 1); + } + + public static void Disable() + { + Volatile.Write(ref _enabled, 0); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor new file mode 100644 index 000000000000..efd16d78eea0 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor @@ -0,0 +1,5 @@ +@page "/activity-links/circuit-initialization" + +

Activity links circuit initialization

+ + diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor new file mode 100644 index 000000000000..cd9fc5025faf --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + + + +@Body diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor new file mode 100644 index 000000000000..032a88e151f0 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor @@ -0,0 +1,6 @@ +@page "/activity-links/navigation/one" +@layout ActivityLinksLayout + +

Activity links navigation one

+ +Navigate diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor new file mode 100644 index 000000000000..f93cb563ccfb --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor @@ -0,0 +1,4 @@ +@page "/activity-links/navigation/two" +@layout ActivityLinksLayout + +

Activity links navigation two

diff --git a/src/Shared/Components/ComponentsActivityLinkStore.cs b/src/Shared/Components/ComponentsActivityLinkStore.cs index a8c882b0d260..92221c2c38ab 100644 --- a/src/Shared/Components/ComponentsActivityLinkStore.cs +++ b/src/Shared/Components/ComponentsActivityLinkStore.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Text.Json.Serialization; using Microsoft.AspNetCore.Components.RenderTree; // this internal helper class is used in both Components and Components.Server projects as a different type @@ -26,7 +25,6 @@ internal class ComponentsActivityLinkStore public const string Route = "Route"; public const string Circuit = "Circuit"; public const string Event = "Event"; - public const string PersistentRouteStateKey = "__aspnetcore.components.route.activity"; private readonly Dictionary _store; @@ -131,8 +129,4 @@ internal sealed record ComponentsActivityPersistentState( bool IsRemote, string Route); -[JsonSerializable(typeof(ComponentsActivityPersistentState))] -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true)] -internal sealed partial class ComponentsActivityPersistentStateJsonContext : JsonSerializerContext; +internal sealed record ComponentsActivityPersistentStateUpdate(ComponentsActivityPersistentState? Route); From c374ec9c68dbdbc54b56f04c66ee2eeb12d1daa0 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:40:46 +0200 Subject: [PATCH 3/3] Test Blazor activity links across runtimes Persist route activity state to Server and WebAssembly runtimes and add per-page initialization and enhanced-navigation E2E coverage backed by OpenTelemetry exporters and a correlated JSON collector. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Components/src/ComponentsActivityState.cs | 45 +++-- ...eringMetricsServiceCollectionExtensions.cs | 48 ++++- .../test/ComponentsActivityStateTest.cs | 27 ++- .../test/RazorComponentEndpointInvokerTest.cs | 18 -- .../ServerRazorComponentsBuilderExtensions.cs | 3 +- ...ssemblyRazorComponentsBuilderExtensions.cs | 3 + .../src/Hosting/WebAssemblyHostBuilder.cs | 3 + .../ServerRenderingTests/InteractivityTest.cs | 181 +++++++++++++++--- .../RazorComponentEndpointsStartup.cs | 26 ++- .../Components/ActivityLinksProbe.razor | 46 ----- .../ComponentsActivityTestCollector.cs | 94 +++++++++ .../ComponentsActivityTestListener.cs | 32 ---- .../ActivityLinksCircuitInitialization.razor | 16 +- .../Interactivity/ActivityLinksLayout.razor | 2 +- .../ActivityLinksNavigationOne.razor | 9 +- .../ActivityLinksNavigationTwo.razor | 2 +- .../ActivityLinksWebAssemblyLayout.razor | 5 + ...ctivityLinksWebAssemblyNavigationOne.razor | 11 ++ ...ctivityLinksWebAssemblyNavigationTwo.razor | 4 + .../ActivityLinksWebAssemblyExporter.cs | 44 +++++ .../Components.WasmMinimal.csproj | 5 + .../Components.WasmMinimal/Program.cs | 13 ++ .../ActivityLinksTestTelemetry.cs | 30 +++ .../ActivityLinksTestTrigger.razor | 24 +++ 24 files changed, 527 insertions(+), 164 deletions(-) delete mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.cs delete mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor create mode 100644 src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs create mode 100644 src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs create mode 100644 src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor diff --git a/src/Components/Components/src/ComponentsActivityState.cs b/src/Components/Components/src/ComponentsActivityState.cs index 4aaba2b50a0f..7efc2d9c4bce 100644 --- a/src/Components/Components/src/ComponentsActivityState.cs +++ b/src/Components/Components/src/ComponentsActivityState.cs @@ -11,21 +11,18 @@ internal sealed class ComponentsActivityState private ComponentsActivityPersistentStateUpdate? _pendingState; private bool _hasPendingState; - [PersistentState(AllowUpdates = true)] - public ComponentsActivityPersistentStateUpdate ActivityState + public ComponentsActivityPersistentStateUpdate Capture() { - get - { - ComponentsActivityPersistentState? routeState = null; - _activityLinkStore?.TryCreatePersistentRouteState(out routeState); - return new ComponentsActivityPersistentStateUpdate(routeState); - } - set - { - _pendingState = value; - _hasPendingState = true; - ApplyPendingState(); - } + ComponentsActivityPersistentState? routeState = null; + _activityLinkStore?.TryCreatePersistentRouteState(out routeState); + return new ComponentsActivityPersistentStateUpdate(routeState); + } + + public void Apply(ComponentsActivityPersistentStateUpdate state) + { + _pendingState = state; + _hasPendingState = true; + ApplyPendingState(); } public void Initialize(ComponentsActivityLinkStore activityLinkStore) @@ -54,3 +51,23 @@ private void ApplyPendingState() _hasPendingState = false; } } + +internal sealed class ServerComponentsActivityState(ComponentsActivityState activityState) +{ + [PersistentState(AllowUpdates = true)] + public ComponentsActivityPersistentStateUpdate ActivityState + { + get => activityState.Capture(); + set => activityState.Apply(value); + } +} + +internal sealed class WebAssemblyComponentsActivityState(ComponentsActivityState activityState) +{ + [PersistentState(AllowUpdates = true)] + public ComponentsActivityPersistentStateUpdate ActivityState + { + get => activityState.Capture(); + set => activityState.Apply(value); + } +} diff --git a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs index e0a96da86a98..eb28b3b90f8d 100644 --- a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs +++ b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs @@ -1,9 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components.Infrastructure; @@ -39,23 +42,55 @@ public static IServiceCollection AddComponentsTracing( IServiceCollection services) { services.TryAddScoped(); - services.TryAddScoped(); + if (services.Any(descriptor => + descriptor.ServiceType == typeof(IComponentRenderMode) && + Equals(descriptor.ServiceKey, "Microsoft.AspNetCore.Components.ActivityState.WebAssembly"))) + { + services.TryAddSingleton(); + } + else + { + services.TryAddScoped(); + } + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.Server"); services.TryAddKeyedSingleton( - typeof(ComponentsMetricsServiceCollectionExtensions)); + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly"); services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + ServiceDescriptor.Singleton()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); return services; } private sealed class UnsupportedComponentsActivityStateRenderMode : IComponentRenderMode; - private sealed class ComponentsActivityStatePersistentServiceRegistration(IServiceProvider serviceProvider) + private sealed class ServerComponentsActivityStatePersistentServiceRegistration(IServiceProvider serviceProvider) + : IPersistentServiceRegistration + { + private readonly PersistentServiceRegistration _registration = new( + serviceProvider.GetRequiredKeyedService( + "Microsoft.AspNetCore.Components.ActivityState.Server")); + + public string Assembly => _registration.Assembly; + + public string FullTypeName => _registration.FullTypeName; + + public IComponentRenderMode? GetRenderModeOrDefault() => _registration.GetRenderModeOrDefault(); + + [DynamicDependency(JsonSerialized, typeof(ServerComponentsActivityState))] + public Type? GetResolvedTypeOrNull() => _registration.GetResolvedTypeOrNull(); + } + + private sealed class WebAssemblyComponentsActivityStatePersistentServiceRegistration(IServiceProvider serviceProvider) : IPersistentServiceRegistration { - private readonly PersistentServiceRegistration _registration = new( + private readonly PersistentServiceRegistration _registration = new( serviceProvider.GetRequiredKeyedService( - typeof(ComponentsMetricsServiceCollectionExtensions))); + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly")); public string Assembly => _registration.Assembly; @@ -63,6 +98,7 @@ private sealed class ComponentsActivityStatePersistentServiceRegistration(IServi public IComponentRenderMode? GetRenderModeOrDefault() => _registration.GetRenderModeOrDefault(); + [DynamicDependency(JsonSerialized, typeof(WebAssemblyComponentsActivityState))] public Type? GetResolvedTypeOrNull() => _registration.GetResolvedTypeOrNull(); } diff --git a/src/Components/Components/test/ComponentsActivityStateTest.cs b/src/Components/Components/test/ComponentsActivityStateTest.cs index f3f9976e816f..a2eaa2ce9921 100644 --- a/src/Components/Components/test/ComponentsActivityStateTest.cs +++ b/src/Components/Components/test/ComponentsActivityStateTest.cs @@ -12,10 +12,8 @@ public class ComponentsActivityStateTest public void CircuitInitialization_AppliesStateRestoredBeforeRendererInitialization() { var routeContext = CreateActivityContext(); - var activityState = new ComponentsActivityState - { - ActivityState = CreateState(routeContext, "/counter") - }; + var activityState = new ComponentsActivityState(); + activityState.Apply(CreateState(routeContext, "/counter")); var linkStore = new ComponentsActivityLinkStore(null); activityState.Initialize(linkStore); @@ -31,9 +29,9 @@ public void NavigationUpdate_ReplacesRouteWhenInteractiveTreeHasNoRouter() var linkStore = new ComponentsActivityLinkStore(null); var activityState = new ComponentsActivityState(); activityState.Initialize(linkStore); - activityState.ActivityState = CreateState(initialContext, "/initial"); + activityState.Apply(CreateState(initialContext, "/initial")); - activityState.ActivityState = CreateState(updatedContext, "/updated"); + activityState.Apply(CreateState(updatedContext, "/updated")); AssertRoute(linkStore, updatedContext, "/updated"); } @@ -44,9 +42,22 @@ public void NavigationUpdate_ClearsRouteWhenStateHasNoRoute() var linkStore = new ComponentsActivityLinkStore(null); var activityState = new ComponentsActivityState(); activityState.Initialize(linkStore); - activityState.ActivityState = CreateState(CreateActivityContext(), "/counter"); + activityState.Apply(CreateState(CreateActivityContext(), "/counter")); + + activityState.Apply(new ComponentsActivityPersistentStateUpdate(null)); + + Assert.False(linkStore.TryGetActivityContext(ComponentsActivityLinkStore.Route, out _, out _)); + } - activityState.ActivityState = new ComponentsActivityPersistentStateUpdate(null); + [Fact] + public void CircuitInitialization_IgnoresMalformedActivityContext() + { + var activityState = new ComponentsActivityState(); + activityState.Apply(new ComponentsActivityPersistentStateUpdate( + new ComponentsActivityPersistentState("invalid", null, false, "/counter"))); + var linkStore = new ComponentsActivityLinkStore(null); + + activityState.Initialize(linkStore); Assert.False(linkStore.TryGetActivityContext(ComponentsActivityLinkStore.Route, out _, out _)); } diff --git a/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs b/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs index 0b4c33ee968d..8dc20784a35f 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs +++ b/src/Components/Endpoints/test/RazorComponentEndpointInvokerTest.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents; -using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -20,23 +19,6 @@ namespace Microsoft.AspNetCore.Components.Endpoints.Tests; public class RazorComponentEndpointInvokerTest { - [Fact] - public async Task ComponentsActivityState_IsNotPersistedForWebAssemblyStore() - { - var services = new ServiceCollection().AddRazorComponents() - .Services.AddSingleton(new ConfigurationBuilder().Build()) - .AddSingleton(new TestWebHostEnvironment()) - .BuildServiceProvider(); - var renderer = new EndpointHtmlRenderer(services, NullLoggerFactory.Instance); - var persistenceManager = services.GetRequiredService(); - await persistenceManager.RestoreStateAsync(new PrerenderComponentApplicationStore()); - var store = new PrerenderComponentApplicationStore(); - - await persistenceManager.PersistStateAsync(store, renderer); - - Assert.Null(store.PersistedState); - } - [Fact] public async Task Invoker_RejectsPostRequestsWithNonFormDataContentTypesAsync() { diff --git a/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs b/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs index 8baacbc9a719..df3d34540f26 100644 --- a/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Endpoints.Infrastructure; -using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; @@ -37,7 +36,7 @@ public static IServerSideBlazorBuilder AddInteractiveServerComponents(this IRazo builder.Services.AddServerSideBlazor(configure); builder.Services.AddKeyedSingleton( - typeof(ComponentsMetricsServiceCollectionExtensions), + "Microsoft.AspNetCore.Components.ActivityState.Server", RenderMode.InteractiveServer); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs index cfbf3596b64a..79ef98a04335 100644 --- a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs @@ -27,6 +27,9 @@ public static IRazorComponentsBuilder AddInteractiveWebAssemblyComponents(this I { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + builder.Services.AddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly", + RenderMode.InteractiveWebAssembly); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddScoped(); diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 7ad8803dd768..cc9de27a0b81 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -358,6 +358,9 @@ internal void InitializeDefaultServices() // Register metrics and tracing when supported and not disabled by the feature switch. if (IsMeterSupported && IsMeterEnabled) { + Services.AddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly", + RenderMode.InteractiveWebAssembly); ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Services); ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Services); } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index dae01caabda8..d2c317e3af65 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Http; +using System.Net.Http.Json; using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; @@ -9,6 +11,7 @@ using Microsoft.AspNetCore.E2ETesting; using OpenQA.Selenium; using TestServer; +using TestContentPackage; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; @@ -27,41 +30,159 @@ public InteractivityTest( public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); - [Fact] - public void PerPageActivityLinks_AreRestoredWhenCircuitIsInitialized() + [Theory] + [InlineData("server")] + [InlineData("webassembly")] + public async Task PerPageActivityLinks_AreRestoredWhenInteractiveRuntimeIsInitialized(string mode) { - Navigate($"{ServerPathBase}/activity-links/circuit-initialization"); + var testId = Guid.NewGuid().ToString("N"); + try + { + Navigate($"{ServerPathBase}/activity-links/{mode}/circuit-initialization?activity-links-test-id={testId}"); - Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-probe-interactive")).Text); - Browser.Click(By.Id("activity-links-capture")); + Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-interactive")).Text); + Browser.Click(By.Id("activity-links-trigger")); + Browser.Equal("1", () => Browser.FindElement(By.Id("activity-links-trigger-count")).Text); - Browser.Equal( - "/activity-links/circuit-initialization", - () => Browser.FindElement(By.Id("activity-links-route")).Text); - Browser.Equal("2", () => Browser.FindElement(By.Id("activity-links-count")).Text); + var expectedOperations = mode == "server" + ? new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Server.Circuits.StartCircuit", + "Microsoft.AspNetCore.Components.HandleEvent", + } + : new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.HandleEvent", + }; + var telemetry = await GetActivityLinksTelemetryAsync(testId, expectedOperations.Length); + AssertActivityLinksTelemetry( + telemetry, + expectedOperations, + "/activity-links/{Mode}/circuit-initialization", + expectCircuit: mode == "server"); + } + finally + { + await CompleteActivityLinksTelemetryAsync(testId); + } } - [Fact] - public void PerPageActivityLinks_AreUpdatedWhenCircuitReceivesEnhancedNavigation() - { - Navigate($"{ServerPathBase}/activity-links/navigation/one"); - - Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-probe-interactive")).Text); - var instanceId = Browser.FindElement(By.Id("activity-links-probe-instance")).Text; - - Browser.Click(By.Id("activity-links-navigate")); - - Browser.Equal( - "Activity links navigation two", - () => Browser.FindElement(By.TagName("h1")).Text); - Browser.Equal( - instanceId, - () => Browser.FindElement(By.Id("activity-links-probe-instance")).Text); - Browser.Click(By.Id("activity-links-capture")); - Browser.Equal( - "/activity-links/navigation/two", - () => Browser.FindElement(By.Id("activity-links-route")).Text); - Browser.Equal("2", () => Browser.FindElement(By.Id("activity-links-count")).Text); + [Theory] + [InlineData("server")] + [InlineData("webassembly")] + public async Task PerPageActivityLinks_AreUpdatedAfterEnhancedNavigation(string mode) + { + var testId = Guid.NewGuid().ToString("N"); + try + { + Navigate($"{ServerPathBase}/activity-links/{mode}/navigation/one?activity-links-test-id={testId}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-interactive")).Text); + var instanceId = Browser.FindElement(By.Id("activity-links-instance")).Text; + + Browser.Click(By.Id("activity-links-navigate")); + + Browser.Equal( + "Activity links navigation two", + () => Browser.FindElement(By.TagName("h1")).Text); + Browser.Equal( + instanceId, + () => Browser.FindElement(By.Id("activity-links-instance")).Text); + Browser.Click(By.Id("activity-links-trigger")); + Browser.Equal("1", () => Browser.FindElement(By.Id("activity-links-trigger-count")).Text); + + var expectedOperations = mode == "server" + ? new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Server.Circuits.StartCircuit", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.HandleEvent", + } + : new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.HandleEvent", + }; + var telemetry = await GetActivityLinksTelemetryAsync(testId, expectedOperations.Length); + AssertActivityLinksTelemetry( + telemetry, + expectedOperations, + $"/activity-links/{mode}/navigation/two", + expectCircuit: mode == "server"); + } + finally + { + await CompleteActivityLinksTelemetryAsync(testId); + } + } + + private async Task GetActivityLinksTelemetryAsync(string testId, int expectedCount) + { + using var client = new HttpClient { BaseAddress = _serverFixture.RootUri }; + for (var i = 0; i < 100; i++) + { + var telemetry = await client.GetFromJsonAsync( + $"{ServerPathBase}/activity-links/telemetry/{testId}"); + if (telemetry is { Length: >= 1 } && telemetry.Length >= expectedCount) + { + return telemetry; + } + await Task.Delay(100); + } + + return []; + } + + private async Task CompleteActivityLinksTelemetryAsync(string testId) + { + using var client = new HttpClient { BaseAddress = _serverFixture.RootUri }; + await client.DeleteAsync($"{ServerPathBase}/activity-links/telemetry/{testId}"); + } + + private static void AssertActivityLinksTelemetry( + ActivityLinksTestSpan[] telemetry, + string[] expectedOperations, + string expectedRoute, + bool expectCircuit) + { + var actualOperations = telemetry.Select(span => span.Name).ToArray(); + Assert.True( + expectedOperations.SequenceEqual(actualOperations), + $"Expected: {string.Join(", ", expectedOperations)}{Environment.NewLine}" + + $"Actual: {string.Join(", ", telemetry.Select(span => $"{span.Name} route={span.Route} trace={span.TraceId} span={span.SpanId}"))}"); + var route = telemetry.Last(span => span.Name == "Microsoft.AspNetCore.Components.Navigate"); + var eventActivity = Assert.Single( + telemetry.Where(span => span.Name == "Microsoft.AspNetCore.Components.HandleEvent")); + Assert.Equal(expectedRoute, eventActivity.Route); + Assert.Contains( + eventActivity.Links, + link => link.TraceId == route.TraceId && link.SpanId == route.SpanId); + + var circuit = telemetry.SingleOrDefault( + span => span.Name == "Microsoft.AspNetCore.Components.Server.Circuits.StartCircuit"); + if (expectCircuit) + { + Assert.NotNull(circuit); + Assert.Equal(circuit.CircuitId, eventActivity.CircuitId); + Assert.Contains( + eventActivity.Links, + link => link.TraceId == circuit.TraceId && link.SpanId == circuit.SpanId); + } + else + { + Assert.Null(circuit); + Assert.Null(eventActivity.CircuitId); + } } [Fact] diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 31840e9af084..eedad05e341d 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -17,6 +17,8 @@ using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; +using OpenTelemetry; +using OpenTelemetry.Trace; using TestContentPackage; using TestContentPackage.Services; @@ -55,6 +57,11 @@ public void ConfigureServices(IServiceCollection services) new TestStringLocalizerFactory(ClientValidationLocalizationData.Translations)); services.AddValidation(options => options.Resolvers.Add(new BasicTestApp.FormsTest.AsyncValidationResolver())); + services.AddOpenTelemetry().WithTracing(tracing => tracing + .AddSource("Microsoft.AspNetCore.Components") + .AddSource("Microsoft.AspNetCore.Components.Server.Circuits") + .SetSampler(new ComponentsActivityTestSampler()) + .AddProcessor(new SimpleActivityExportProcessor(new ComponentsActivityTestExporter()))); // Increase 10 MB hub message limit (default 32 KB) if (Configuration.GetValue("AllowLargeHubMessages")) @@ -228,9 +235,9 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen app.Use((ctx, nxt) => { - if (ctx.Request.Path.StartsWithSegments("/activity-links")) + if (ctx.Request.Query.TryGetValue("activity-links-test-id", out var testId)) { - ComponentsActivityTestListener.Enable(); + ComponentsActivityTestCollector.Start(testId.ToString()); } if (ctx.Request.Query.ContainsKey("add-csp")) @@ -291,6 +298,21 @@ private void ConfigureEndpoints(IApplicationBuilder app, IWebHostEnvironment env MapEnhancedNavigationEndpoints(endpoints); endpoints.MapAutoPauseTestEndpoints(); + endpoints.MapPost("activity-links/telemetry/{testId}", async (string testId, HttpRequest request) => + { + if (await request.ReadFromJsonAsync() is { } spans) + { + ComponentsActivityTestCollector.Add(testId, spans); + } + return Results.Ok(); + }); + endpoints.MapGet("activity-links/telemetry/{testId}", (string testId) => + ComponentsActivityTestCollector.Get(testId)); + endpoints.MapDelete("activity-links/telemetry/{testId}", (string testId) => + { + ComponentsActivityTestCollector.Complete(testId); + return Results.NoContent(); + }); }); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor deleted file mode 100644 index e8ade6b41cd6..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ActivityLinksProbe.razor +++ /dev/null @@ -1,46 +0,0 @@ -@using System.Diagnostics - - - -@code { - private readonly string _instanceId = Guid.NewGuid().ToString("N"); - private bool _interactive; - private string? _route; - private int? _linkCount; - - protected override void OnAfterRender(bool firstRender) - { - if (firstRender) - { - _interactive = true; - StateHasChanged(); - } - } - - private void CaptureActivity() - { - _ = CaptureActivityAfterEventAsync(Activity.Current); - } - - private async Task CaptureActivityAfterEventAsync(Activity? activity) - { - while (activity is { IsStopped: false }) - { - await Task.Delay(10); - } - - await InvokeAsync(() => - { - _route = activity?.GetTagItem("aspnetcore.components.route") as string; - _linkCount = activity?.Links.Count(); - ComponentsActivityTestListener.Disable(); - StateHasChanged(); - }); - } -} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.cs new file mode 100644 index 000000000000..92e01e0e0fb6 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using OpenTelemetry; +using OpenTelemetry.Trace; +using TestContentPackage; + +namespace Components.TestServer.RazorComponents; + +internal static class ComponentsActivityTestCollector +{ + private static readonly object _lock = new(); + private static readonly Dictionary> _spans = []; + private static string? _activeTestId; + + public static bool IsActive => Volatile.Read(ref _activeTestId) is not null; + + public static void Start(string testId) + { + lock (_lock) + { + _spans.TryAdd(testId, []); + Volatile.Write(ref _activeTestId, testId); + } + } + + public static void AddServer(Activity activity) + { + var testId = Volatile.Read(ref _activeTestId); + if (testId is not null) + { + Add(testId, [ActivityLinksTestSpan.FromActivity(activity)]); + } + } + + public static void Add(string testId, IEnumerable spans) + { + lock (_lock) + { + if (_spans.TryGetValue(testId, out var collected)) + { + foreach (var span in spans) + { + if (!collected.Any(existing => existing.TraceId == span.TraceId && existing.SpanId == span.SpanId)) + { + collected.Add(span); + } + } + } + } + } + + public static ActivityLinksTestSpan[] Get(string testId) + { + lock (_lock) + { + return _spans.TryGetValue(testId, out var spans) ? [.. spans] : []; + } + } + + public static void Complete(string testId) + { + lock (_lock) + { + _spans.Remove(testId); + if (_activeTestId == testId) + { + Volatile.Write(ref _activeTestId, null); + } + } + } +} + +internal sealed class ComponentsActivityTestSampler : Sampler +{ + public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) + => ComponentsActivityTestCollector.IsActive + ? new SamplingResult(SamplingDecision.RecordAndSample) + : new SamplingResult(SamplingDecision.Drop); +} + +internal sealed class ComponentsActivityTestExporter : BaseExporter +{ + public override ExportResult Export(in Batch batch) + { + foreach (var activity in batch) + { + ComponentsActivityTestCollector.AddServer(activity); + } + + return ExportResult.Success; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs deleted file mode 100644 index 0b2ed19123d9..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestListener.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; - -namespace Components.TestServer.RazorComponents; - -internal static class ComponentsActivityTestListener -{ - private static int _enabled; - private static readonly ActivityListener _listener = new() - { - ShouldListenTo = source => source.Name is "Microsoft.AspNetCore.Components" or "Microsoft.AspNetCore.Components.Server.Circuits", - Sample = (ref ActivityCreationOptions _) => - Volatile.Read(ref _enabled) == 1 ? ActivitySamplingResult.AllData : ActivitySamplingResult.None, - }; - - static ComponentsActivityTestListener() - { - ActivitySource.AddActivityListener(_listener); - } - - public static void Enable() - { - Volatile.Write(ref _enabled, 1); - } - - public static void Disable() - { - Volatile.Write(ref _enabled, 0); - } -} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor index efd16d78eea0..6c3a3e3de4c1 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor @@ -1,5 +1,17 @@ -@page "/activity-links/circuit-initialization" +@page "/activity-links/{Mode}/circuit-initialization"

Activity links circuit initialization

- +@if (Mode == "server") +{ + +} +else if (Mode == "webassembly") +{ + +} + +@code { + [Parameter] + public string Mode { get; set; } = ""; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor index cd9fc5025faf..21912ba49932 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase - + @Body diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor index 032a88e151f0..221fce490f73 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor @@ -1,6 +1,11 @@ -@page "/activity-links/navigation/one" +@page "/activity-links/server/navigation/one" @layout ActivityLinksLayout

Activity links navigation one

-Navigate +Navigate + +@code { + [SupplyParameterFromQuery(Name = "activity-links-test-id")] + public string? TestId { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor index f93cb563ccfb..6c783236a3e2 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor @@ -1,4 +1,4 @@ -@page "/activity-links/navigation/two" +@page "/activity-links/server/navigation/two" @layout ActivityLinksLayout

Activity links navigation two

diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor new file mode 100644 index 000000000000..a6f22b2fe73d --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + + + +@Body diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor new file mode 100644 index 000000000000..4d767ba894a5 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor @@ -0,0 +1,11 @@ +@page "/activity-links/webassembly/navigation/one" +@layout ActivityLinksWebAssemblyLayout + +

Activity links navigation one

+ +Navigate + +@code { + [SupplyParameterFromQuery(Name = "activity-links-test-id")] + public string? TestId { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor new file mode 100644 index 000000000000..06cde195eb2c --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor @@ -0,0 +1,4 @@ +@page "/activity-links/webassembly/navigation/two" +@layout ActivityLinksWebAssemblyLayout + +

Activity links navigation two

diff --git a/src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs b/src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs new file mode 100644 index 000000000000..fc0d1a3863a6 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net.Http; +using System.Net.Http.Json; +using OpenTelemetry; +using TestContentPackage; + +namespace Components.WasmMinimal; + +internal sealed class ActivityLinksWebAssemblyExporter(HttpClient client, string testId) : BaseExporter +{ + private readonly object _lock = new(); + private Task _pendingExport = Task.CompletedTask; + + public override ExportResult Export(in Batch batch) + { + var spans = new List(); + foreach (var activity in batch) + { + spans.Add(ActivityLinksTestSpan.FromActivity(activity)); + } + lock (_lock) + { + _pendingExport = ExportAfterAsync(_pendingExport, [.. spans]); + } + return ExportResult.Success; + } + + private async Task ExportAfterAsync(Task previousExport, ActivityLinksTestSpan[] spans) + { + try + { + await previousExport; + using var response = await client.PostAsJsonAsync($"activity-links/telemetry/{testId}", spans); + response.EnsureSuccessStatusCode(); + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + } + } +} diff --git a/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj b/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj index c52c9993ecb7..cc36c20bc7d7 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj +++ b/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj @@ -8,11 +8,16 @@ true false true + true + true + true + true + diff --git a/src/Components/test/testassets/Components.WasmMinimal/Program.cs b/src/Components/test/testassets/Components.WasmMinimal/Program.cs index a352de4f1d3c..b2c6b44b32b6 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Program.cs +++ b/src/Components/test/testassets/Components.WasmMinimal/Program.cs @@ -2,12 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Net.Http; using System.Runtime.InteropServices.JavaScript; using System.Security.Claims; using Components.TestServer.Services; +using Components.WasmMinimal; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using OpenTelemetry; +using OpenTelemetry.Trace; using TestContentPackage; using TestContentPackage.Services; @@ -26,6 +30,15 @@ builder.Services.AddCascadingAuthenticationState(); +if (JSImports.GetQueryParam("activity-links-test-id") is { Length: > 0 } activityLinksTestId) +{ + var client = new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }; + builder.Services.AddOpenTelemetry().WithTracing(tracing => tracing + .AddSource("Microsoft.AspNetCore.Components") + .AddProcessor(new SimpleActivityExportProcessor( + new ActivityLinksWebAssemblyExporter(client, activityLinksTestId)))); +} + builder.Services.AddAuthenticationStateDeserialization(options => { var originalCallback = options.DeserializationCallback; diff --git a/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs new file mode 100644 index 000000000000..795fc558038b --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Diagnostics; + +namespace TestContentPackage; + +public sealed record ActivityLinksTestSpan( + string Name, + string TraceId, + string SpanId, + string? Route, + string? CircuitId, + ActivityLinksTestLink[] Links) +{ + public static ActivityLinksTestSpan FromActivity(Activity activity) + => new( + activity.OperationName, + activity.TraceId.ToString(), + activity.SpanId.ToString(), + activity.GetTagItem("aspnetcore.components.route") as string, + activity.GetTagItem("aspnetcore.components.circuit.id") as string, + [.. activity.Links.Select(link => new ActivityLinksTestLink( + link.Context.TraceId.ToString(), + link.Context.SpanId.ToString()))]); +} + +public sealed record ActivityLinksTestLink(string TraceId, string SpanId); diff --git a/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor new file mode 100644 index 000000000000..f3ea5ed93fe3 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor @@ -0,0 +1,24 @@ +@_instanceId +@_interactive + +@_count + +@code { + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private bool _interactive; + private int _count; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _interactive = true; + StateHasChanged(); + } + } + + private void Trigger() + { + _count++; + } +}