diff --git a/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs b/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs index 11eef76595ff..6e70b7f281ab 100644 --- a/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs +++ b/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -25,17 +26,44 @@ public TempData CreateEmpty(HttpContext httpContext) return _tempDataProvider.LoadTempData(httpContext); } - public void Save(HttpContext httpContext, TempData tempData) + public void Save(HttpContext httpContext, ITempData tempData) { if (httpContext.RequestServices.GetService() is { } supplier) { supplier.PersistValues(tempData); } - if (!tempData.WasLoaded) + if (tempData is not TempData data || !data.WasLoaded) { return; } - _tempDataProvider.SaveTempData(httpContext, tempData.Save()); + _tempDataProvider.SaveTempData(httpContext, data.Save()); + } + + public void Persist(HttpContext httpContext) + { + if (!httpContext.Items.TryGetValue(TempDataProviderServiceCollectionExtensions.HttpContextItemKey, out var tempDataObj) + || tempDataObj is not ITempData tempData) + { + return; + } + + if (_tempDataProvider is CookieTempDataProvider && httpContext.Response.HasStarted) + { + var logger = httpContext.RequestServices.GetRequiredService().CreateLogger(typeof(TempDataService).FullName!); + Log.CookieTempDataNotPersistedAfterResponseStarted(logger); + return; + } + + Save(httpContext, tempData); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, + "TempData values written during or after streaming SSR cannot be persisted by the cookie TempData provider because the response has already started. " + + "Switch to the session-storage TempData provider (RazorComponentsServiceOptions.TempDataProviderType = TempDataProviderType.SessionStorage) to enable persistence in streaming SSR scenarios.", + EventName = "CookieTempDataNotPersistedAfterResponseStarted")] + public static partial void CookieTempDataNotPersistedAfterResponseStarted(ILogger logger); } } diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 22119d0522c9..d472b7cc6cb3 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -173,6 +173,14 @@ await _renderer.InitializeStandardComponentServicesAsync( _renderer.EmitInitializersIfNecessary(context, bufferWriter); } + // Persist TempData and Session values after all components (including streaming) + // have finished rendering, so that values modified during async rendering are captured. + if (context.RequestServices.GetService() is { } sessionSupplier) + { + await sessionSupplier.PersistAllValues(); + } + context.RequestServices.GetRequiredService().Persist(context); + // Emit comment containing state. if (!isErrorHandlerOrReExecuted) { diff --git a/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs index 27db32a80eff..33ce74b0fc36 100644 --- a/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs +++ b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs @@ -18,7 +18,6 @@ internal partial class SessionCascadingValueSupplier private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private HttpContext? _httpContext; - private bool _onStartingRegistered; private readonly Dictionary> _valueCallbacks = new(StringComparer.OrdinalIgnoreCase); private readonly ILogger _logger; @@ -37,10 +36,10 @@ internal CascadingParameterSubscription CreateSubscription( SupplyParameterFromSessionAttribute attribute, CascadingParameterInfo parameterInfo) { - if (!_onStartingRegistered && _httpContext is not null) + if (_httpContext is not null) { - _onStartingRegistered = true; - _httpContext.Response.OnStarting(PersistAllValues); + // Ensure that session cookie is issued to allow for persistence from streaming context + SessionEstablishmentHelper.TryRegisterSessionEstablishment(_httpContext); } var sessionKey = attribute.Name ?? parameterInfo.PropertyName; diff --git a/src/Components/Endpoints/src/SessionEstablishmentHelper.cs b/src/Components/Endpoints/src/SessionEstablishmentHelper.cs new file mode 100644 index 000000000000..654917c1c459 --- /dev/null +++ b/src/Components/Endpoints/src/SessionEstablishmentHelper.cs @@ -0,0 +1,73 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal static partial class SessionEstablishmentHelper +{ + private const string SessionEstablishmentKey = "__AspNetCore.Components.Endpoints.SessionEstablishment"; + private const string LoggedResponseHasStartedKey = "__AspNetCore.Components.Endpoints.SessionEstablishment.LoggedResponseHasStarted"; + private const string LoggedSessionDoesNotExistKey = "__AspNetCore.Components.Endpoints.SessionEstablishment.LoggedSessionDoesNotExist"; + + public static void TryRegisterSessionEstablishment(HttpContext context) + { + var loggerFactory = context.RequestServices.GetRequiredService(); + var session = context.Features.Get()?.Session; + + if (session == null) + { + if (!context.Items.ContainsKey(LoggedSessionDoesNotExistKey)) + { + Log.SessionDoesNotExist(loggerFactory.CreateLogger(typeof(SessionEstablishmentHelper).FullName!)); + context.Items[LoggedSessionDoesNotExistKey] = true; + } + return; + } + + if (context.Response.HasStarted) + { + if (!context.Items.ContainsKey(LoggedResponseHasStartedKey)) + { + Log.ResponseHasStarted(loggerFactory.CreateLogger(typeof(SessionEstablishmentHelper).FullName!)); + context.Items[LoggedResponseHasStartedKey] = true; + } + return; + } + + if (context.Items.ContainsKey(SessionEstablishmentKey)) + { + return; + } + + context.Items[SessionEstablishmentKey] = true; + context.Response.OnStarting(static state => + { + var session = (ISession)state!; + session.Set(SessionEstablishmentKey, []); + session.Remove(SessionEstablishmentKey); + return Task.CompletedTask; + }, session); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, + "Session state was not persisted to the next request. " + + "The response has already started, so the session cookie can no longer be issued. " + + "To avoid this, place at least one [SupplyParameterFromSession] (or use Session.Set directly) before any await that triggers the first response flush.", + EventName = "SessionStateNotPersistedAfterResponseStarted")] + public static partial void ResponseHasStarted(ILogger logger); + + [LoggerMessage(2, LogLevel.Warning, + "Session state was not persisted to the next request. " + + "No session is available session middleware was not registered. " + + "To fix this, add 'builder.Services.AddSession()' and 'app.UseSession()' to your app.", + EventName = "SessionDoesNotExist")] + public static partial void SessionDoesNotExist(ILogger logger); + } +} diff --git a/src/Components/Endpoints/src/TempData/TempDataProviderServiceCollectionExtensions.cs b/src/Components/Endpoints/src/TempData/TempDataProviderServiceCollectionExtensions.cs index 49ede31a7986..f1035480b3af 100644 --- a/src/Components/Endpoints/src/TempData/TempDataProviderServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/TempData/TempDataProviderServiceCollectionExtensions.cs @@ -57,11 +57,12 @@ internal static ITempData GetOrCreateTempData(HttpContext httpContext) var tempDataService = httpContext.RequestServices.GetRequiredService(); var tempDataInstance = tempDataService.CreateEmpty(httpContext); httpContext.Items[HttpContextItemKey] = tempDataInstance; - httpContext.Response.OnStarting(() => + + // Ensure that session cookie is issued to allow for persistence from streaming context + if (httpContext.RequestServices.GetService() is SessionStorageTempDataProvider) { - tempDataService.Save(httpContext, tempDataInstance); - return Task.CompletedTask; - }); + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + } return tempDataInstance; } diff --git a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs index 1cde5ad39499..7c4b72210459 100644 --- a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs +++ b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs @@ -71,6 +71,18 @@ public async Task PersistAllValues_RemovesKey_WhenCallbackReturnsNull() Assert.Null(httpContext.Session.GetString("key")); } + [Fact] + public async Task PersistAllValues_KeepsKey_WhenCallbackReturnsValue() + { + var httpContext = CreateHttpContextWithSession(); + + _supplier.RegisterValueCallback("key", () => "value"); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Equal("\"value\"", httpContext.Session.GetString("key")); + } + [Fact] public async Task PersistAllValues_HandlesMultipleKeys() { @@ -160,17 +172,17 @@ public async Task DeleteCallbacks_RemovesCallbacksForKey() } [Fact] - public async Task SetRequestContext_DoesNotRegisterOnStarting_UntilSubscriptionCreated() + public async Task SetRequestContext_DoesNotPersist_UntilExplicitlyCalled() { _supplier.RegisterValueCallback("key", () => "value"); - var httpContext = CreateHttpContextWithSession(out var responseFeature); + var httpContext = CreateHttpContextWithSession(); _supplier.SetRequestContext(httpContext); - // OnStarting has not been registered yet because no subscription was created - await responseFeature.FireOnStartingAsync(); - Assert.Null(httpContext.Session.GetString("key")); + + await _supplier.PersistAllValues(); + Assert.Equal("\"value\"", httpContext.Session.GetString("key")); } internal static DefaultHttpContext CreateHttpContextWithSession() diff --git a/src/Components/Endpoints/test/Session/SessionEstablishmentHelperTest.cs b/src/Components/Endpoints/test/Session/SessionEstablishmentHelperTest.cs new file mode 100644 index 000000000000..5d5ba0973c2f --- /dev/null +++ b/src/Components/Endpoints/test/Session/SessionEstablishmentHelperTest.cs @@ -0,0 +1,171 @@ +// 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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class SessionEstablishmentHelperTest +{ + [Fact] + public void TryRegisterSessionEstablishment_LogsSessionDoesNotExist_WhenNoSessionFeature() + { + var sink = new TestSink(); + var httpContext = CreateHttpContext(sink, session: null, responseHasStarted: false); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("SessionDoesNotExist", write.EventId.Name); + } + + [Fact] + public void TryRegisterSessionEstablishment_LogsResponseHasStarted_WhenSessionAvailableButResponseStarted() + { + var sink = new TestSink(); + var httpContext = CreateHttpContext(sink, session: new TestSession(), responseHasStarted: true); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + + var write = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Warning, write.LogLevel); + Assert.Equal("SessionStateNotPersistedAfterResponseStarted", write.EventId.Name); + } + + [Fact] + public void TryRegisterSessionEstablishment_DoesNotLog_WhenSessionAvailableAndResponseNotStarted() + { + var sink = new TestSink(); + var httpContext = CreateHttpContext(sink, session: new TestSession(), responseHasStarted: false); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + + Assert.Empty(sink.Writes); + } + + [Fact] + public void TryRegisterSessionEstablishment_LogsSessionDoesNotExistOncePerRequest() + { + var sink = new TestSink(); + var httpContext = CreateHttpContext(sink, session: null, responseHasStarted: false); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + + var write = Assert.Single(sink.Writes); + Assert.Equal("SessionDoesNotExist", write.EventId.Name); + } + + [Fact] + public void TryRegisterSessionEstablishment_LogsSessionDoesNotExistOncePerEachRequest() + { + var sink = new TestSink(); + var first = CreateHttpContext(sink, session: null, responseHasStarted: false); + var second = CreateHttpContext(sink, session: null, responseHasStarted: false); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(first); + SessionEstablishmentHelper.TryRegisterSessionEstablishment(second); + + Assert.Equal(2, sink.Writes.Count); + Assert.All(sink.Writes, write => Assert.Equal("SessionDoesNotExist", write.EventId.Name)); + } + + [Fact] + public void TryRegisterSessionEstablishment_LogsResponseHasStartedOncePerRequest() + { + var sink = new TestSink(); + var httpContext = CreateHttpContext(sink, session: new TestSession(), responseHasStarted: true); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + SessionEstablishmentHelper.TryRegisterSessionEstablishment(httpContext); + + var write = Assert.Single(sink.Writes); + Assert.Equal("SessionStateNotPersistedAfterResponseStarted", write.EventId.Name); + } + + [Fact] + public void TryRegisterSessionEstablishment_LogsResponseHasStartedOncePerEachRequest() + { + var sink = new TestSink(); + var first = CreateHttpContext(sink, session: new TestSession(), responseHasStarted: true); + var second = CreateHttpContext(sink, session: new TestSession(), responseHasStarted: true); + + SessionEstablishmentHelper.TryRegisterSessionEstablishment(first); + SessionEstablishmentHelper.TryRegisterSessionEstablishment(second); + + Assert.Equal(2, sink.Writes.Count); + Assert.All(sink.Writes, write => Assert.Equal("SessionStateNotPersistedAfterResponseStarted", write.EventId.Name)); + } + + private static DefaultHttpContext CreateHttpContext(TestSink sink, ISession? session, bool responseHasStarted) + { + var responseFeature = new TestHttpResponseFeature { HasStarted = responseHasStarted }; + return CreateHttpContext(sink, session, responseFeature); + } + + private static DefaultHttpContext CreateHttpContext(TestSink sink, ISession? session, TestHttpResponseFeature responseFeature) + { + var services = new ServiceCollection(); + services.AddSingleton(new TestLoggerFactory(sink, enabled: true)); + + var httpContext = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider(), + }; + + if (session is not null) + { + httpContext.Features.Set(new TestSessionFeature(session)); + } + + httpContext.Features.Set(responseFeature); + return httpContext; + } + + private sealed class TestSession : ISession + { + private readonly Dictionary _store = new(); + + public bool IsAvailable => true; + public string Id => "test-session"; + public IEnumerable Keys => _store.Keys; + + public void Clear() => _store.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _store.Remove(key); + public void Set(string key, byte[] value) => _store[key] = value; + public bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out byte[]? value) => _store.TryGetValue(key, out value); + } + + private sealed class TestSessionFeature : ISessionFeature + { + public TestSessionFeature(ISession session) => Session = session; + + public ISession Session { get; set; } + } + + private sealed class TestHttpResponseFeature : IHttpResponseFeature + { + public int OnStartingCount { get; private set; } + + public int StatusCode { get; set; } = 200; + public string? ReasonPhrase { get; set; } + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + public Stream Body { get; set; } = new MemoryStream(); + public bool HasStarted { get; set; } + + public void OnCompleted(Func callback, object state) + { + } + + public void OnStarting(Func callback, object state) => OnStartingCount++; + } +} diff --git a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs index 443b0bb3cf8d..976afc7a3993 100644 --- a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs +++ b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using static Microsoft.AspNetCore.Components.Endpoints.SessionCascadingValueSupplierTest; @@ -127,7 +128,8 @@ public void GetCurrentValue_ReturnsComponentValue_OnSubsequentCalls() [Fact] public async Task CreateSubscription_RegistersValueCallbackAndReturnsSubscription() { - var httpContext = CreateHttpContextWithSession(out var responseFeature); + var httpContext = CreateHttpContextWithSession(); + httpContext.RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(); httpContext.Session.SetString(nameof(TestComponent.Value).ToLowerInvariant(), "\"from-session\""); _supplier.SetRequestContext(httpContext); @@ -142,7 +144,7 @@ public async Task CreateSubscription_RegistersValueCallbackAndReturnsSubscriptio Assert.Equal("from-session", subscription.GetCurrentValue()); _component.Value = "updated"; - await responseFeature.FireOnStartingAsync(); + await _supplier.PersistAllValues(); Assert.Equal("\"updated\"", httpContext.Session.GetString(nameof(TestComponent.Value).ToLowerInvariant())); } diff --git a/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs b/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs index 1065fd2a2df1..4b0c610ae99e 100644 --- a/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs +++ b/src/Components/Endpoints/test/TempData/SessionStorageTempDataProviderTest.cs @@ -34,7 +34,7 @@ public void Load_ReturnsEmptyTempData_WhenNoSessionDataExists() } [Fact] - public void Save_RemovesSessionKey_WhenNoDataToSave() + public void Save_RemovesSessionEntry_WhenNoDataToSave() { var httpContext = CreateHttpContext(); var session = (TestSession)httpContext.Session; @@ -44,6 +44,7 @@ public void Save_RemovesSessionKey_WhenNoDataToSave() _sessionStateTempDataProvider.SaveTempData(httpContext, tempData.Save()); Assert.DoesNotContain(SessionStorageTempDataProvider.TempDataSessionStateKey, session.Keys); + Assert.Empty(_sessionStateTempDataProvider.LoadTempData(httpContext)); } [Fact] diff --git a/src/Components/test/E2ETest/ServerRenderingTests/StreamingSessionPersistenceTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/StreamingSessionPersistenceTest.cs new file mode 100644 index 000000000000..26718bff537e --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/StreamingSessionPersistenceTest.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class StreamingSessionPersistenceTest : ServerTestBase>> +{ + private const string SessionCookieName = ".AspNetCore.Session"; + + public StreamingSessionPersistenceTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + _serverFixture.AdditionalArguments.Add("--UseSessionStorageTempDataProvider=true"); + _serverFixture.AdditionalArguments.Add("--UseSession=true"); + Browser.Manage().Cookies.DeleteCookieNamed(SessionCookieName); + base.InitializeAsyncCore(); + } + + [Fact] + public void StreamingSSR_PersistsSupplyParameterFromSession_AfterAsyncRendering() + { + Navigate($"{ServerPathBase}/streaming-session-persistence"); + + Browser.Exists(By.Id("streaming-complete")); + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Equal("set-during-streaming", () => Browser.FindElement(By.Id("text-email")).Text); + } + + [Fact] + public void StreamingSSR_PersistsSupplyParameterFromTempData_AfterAsyncRendering() + { + Navigate($"{ServerPathBase}/streaming-session-persistence"); + + Browser.Exists(By.Id("streaming-complete")); + Navigate($"{ServerPathBase}/tempdata"); + Browser.Equal("tempdata-set-during-streaming", () => Browser.FindElement(By.Id("supply-parameter-from-tempdata")).Text); + } + + [Fact] + public void StreamingSSR_PersistsTempDataCascadingParameter_AfterAsyncRendering() + { + Navigate($"{ServerPathBase}/streaming-session-persistence"); + + Browser.Exists(By.Id("streaming-complete")); + Navigate($"{ServerPathBase}/tempdata"); + Browser.Equal("streaming-tempdata-message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void StreamingSSR_DeferredChildSubscription_DoesNotPersistSession_OnFirstRequest() + { + Navigate($"{ServerPathBase}/streaming-parent-with-deferred-child"); + + Browser.Exists(By.Id("deferred-child-done")); + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Equal(string.Empty, () => Browser.FindElement(By.Id("text-email")).Text); + } +} diff --git a/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs b/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs index c8a08f8e7ab1..19bde0ee6c09 100644 --- a/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs +++ b/src/Components/test/E2ETest/Tests/TempDataCookieTest.cs @@ -156,4 +156,14 @@ public void SupplyParameterFromTempDataReadsAndSavesValues() Browser.FindElement(By.Id("set-supply-from-tempdata")).Click(); Browser.Equal("Supplied from TempData", () => Browser.FindElement(By.Id("supply-parameter-from-tempdata")).Text); } + + [Fact] + public void StreamingSSR_CookieTempData_DoesNotPersistValuesWrittenAfterFirstFlush() + { + Navigate($"{ServerPathBase}/streaming-session-persistence"); + Browser.Exists(By.Id("streaming-complete")); + + Navigate($"{ServerPathBase}/tempdata"); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingDeferredSessionChild.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingDeferredSessionChild.razor new file mode 100644 index 000000000000..5c3a2c8b908b --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingDeferredSessionChild.razor @@ -0,0 +1,22 @@ +@if (_done) +{ +

Deferred child done

+} +else +{ +

Deferred child waiting...

+} + +@code { + private bool _done; + + [SupplyParameterFromSession] + public string? DeferredChildEmail { get; set; } + + protected override async Task OnInitializedAsync() + { + await Task.Delay(100); + DeferredChildEmail = "deferred-child-set-during-streaming"; + _done = true; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingParentWithDeferredChild.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingParentWithDeferredChild.razor new file mode 100644 index 000000000000..a9bc182b51bf --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingParentWithDeferredChild.razor @@ -0,0 +1,23 @@ +@page "/streaming-parent-with-deferred-child" +@attribute [StreamRendering] + +

Parent that mounts a session-writing child only after streaming begins

+ +@if (_done) +{ + +} +else +{ +

Streaming...

+} + +@code { + private bool _done; + + protected override async Task OnInitializedAsync() + { + await Task.Delay(50); + _done = true; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor new file mode 100644 index 000000000000..a9f3ba676c45 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSessionPersistence.razor @@ -0,0 +1,42 @@ +@page "/streaming-session-persistence" +@attribute [StreamRendering] + +

Streaming Session Persistence Test

+ +@if (_done) +{ +

Done

+} +else +{ +

Waiting for streaming...

+} + +@code { + private bool _done; + + [SupplyParameterFromSession] + public string? Email { get; set; } + + [SupplyParameterFromTempData] + private string SupplyParameterFromTempDataValue { get; set; } = string.Empty; + + [CascadingParameter] + public ITempData? TempData { get; set; } + + protected override async Task OnInitializedAsync() + { + // The await is critical: it causes the component to enter the streaming phase. + // Values set AFTER the await are the ones that were silently lost with the old + // OnStarting-based persistence, because OnStarting fires before streaming begins. + await Task.Delay(100); + + Email = "set-during-streaming"; + SupplyParameterFromTempDataValue = "tempdata-set-during-streaming"; + if (TempData is not null) + { + TempData["Message"] = "streaming-tempdata-message"; + } + _done = true; + } +}