From 896a175fc6fd760f71b11bc92e64baa76ef53006 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 26 May 2025 20:01:09 +0200 Subject: [PATCH 01/23] Circuit persistence implementation --- ...oft.AspNetCore.Components.Endpoints.csproj | 2 + src/Components/Server/src/CircuitOptions.cs | 26 +++ .../Server/src/Circuits/CircuitHost.cs | 18 ++ .../src/Circuits/CircuitPersistenceManager.cs | 156 ++++++++++++++ .../Server/src/Circuits/CircuitRegistry.cs | 12 +- .../Circuits/DefaultPersistedCircuitCache.cs | 168 +++++++++++++++ .../Circuits/ICircuitPersistenceProvider.cs | 12 ++ .../Circuits/IServerComponentDeserializer.cs | 2 +- .../src/Circuits/PersistedCircuitState.cs | 11 + .../Circuits/ServerComponentDeserializer.cs | 12 +- src/Components/Server/src/ComponentHub.cs | 200 +++++++++++++++++- ...rosoft.AspNetCore.Components.Server.csproj | 2 + .../test/CircuitDisconnectMiddlewareTest.cs | 22 +- .../Server/test/Circuits/CircuitHostTest.cs | 2 +- .../test/Circuits/CircuitRegistryTest.cs | 4 +- .../Server/test/Circuits/ComponentHubTest.cs | 5 +- ....AspNetCore.Components.Server.Tests.csproj | 2 - .../Shared/src/WebRootComponentManager.cs | 23 ++ ...ectedPrerenderComponentApplicationStore.cs | 6 + .../ServerComponentInvocationSequence.cs | 0 .../Components}/ServerComponentSerializer.cs | 0 21 files changed, 654 insertions(+), 31 deletions(-) create mode 100644 src/Components/Server/src/Circuits/CircuitPersistenceManager.cs create mode 100644 src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs create mode 100644 src/Components/Server/src/Circuits/ICircuitPersistenceProvider.cs create mode 100644 src/Components/Server/src/Circuits/PersistedCircuitState.cs rename src/{Components/Endpoints/src/DependencyInjection => Shared/Components}/ServerComponentInvocationSequence.cs (100%) rename src/{Components/Endpoints/src/DependencyInjection => Shared/Components}/ServerComponentSerializer.cs (100%) diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj index 4fa9814ea77e..aa481325046c 100644 --- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj +++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj @@ -23,6 +23,8 @@ + + diff --git a/src/Components/Server/src/CircuitOptions.cs b/src/Components/Server/src/CircuitOptions.cs index ec5443f01c9d..fedaa8268ea6 100644 --- a/src/Components/Server/src/CircuitOptions.cs +++ b/src/Components/Server/src/CircuitOptions.cs @@ -44,6 +44,32 @@ public sealed class CircuitOptions /// public TimeSpan DisconnectedCircuitRetentionPeriod { get; set; } = TimeSpan.FromMinutes(3); + /// + /// Gets or sets a value that determines the maximum number of persisted circuits state that + /// are retained in memory by the server when no distributed cache is configured. + /// + /// + /// When using a distributed cache like this value is ignored + /// and the configuration from + /// is used instead. + public int PersistedCircuitMaxRetained { get; set; } = 100; + + /// + /// Gets or sets the duration for which a persisted circuit is retained in memory. + /// + /// This value is used for the default in-memory cache implementation as well as for the + /// duration for which the persisted circuits are retained in the local in memory cache when using a + /// ."/> + public TimeSpan PersistedCircuitInMemoryRetentionPeriod { get; set; } = TimeSpan.FromHours(1); + + /// + /// Gets or sets the duration for which the persisted circuits are retained in the distributed cache when using a + /// . + /// + /// The default value is 8 hours + /// + public TimeSpan PersistedCircuitDistributedRetentionPeriod { get; set; } = TimeSpan.FromHours(8); + /// /// Gets or sets a value that determines whether or not to send detailed exception messages to JavaScript when an unhandled exception /// happens on the circuit or when a .NET method invocation through JS interop results in an exception. diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 38b50461ce3e..9e747e16f7e6 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -32,6 +32,7 @@ internal partial class CircuitHost : IAsyncDisposable private bool _isFirstUpdate = true; private bool _disposed; private long _startTime; + private PersistedCircuitState _persistedCircuitState; // This event is fired when there's an unrecoverable exception coming from the circuit, and // it need so be torn down. The registry listens to this even so that the circuit can @@ -873,6 +874,23 @@ await HandleInboundActivityAsync(() => } } + internal void AttachPersistedState(PersistedCircuitState persistedCircuitState) + { + if (_persistedCircuitState != null) + { + throw new InvalidOperationException("Persisted state has already been attached to this circuit."); + } + + _persistedCircuitState = persistedCircuitState; + } + + internal PersistedCircuitState TakePersistedCircuitState() + { + var result = _persistedCircuitState; + _persistedCircuitState = null; + return result; + } + private static partial class Log { // 100s used for lifecycle stuff diff --git a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs new file mode 100644 index 000000000000..59758f2b460b --- /dev/null +++ b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.DataProtection; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +internal partial class CircuitPersistenceManager( + ComponentStatePersistenceManager persistenceManager, + ServerComponentSerializer serverComponentSerializer, + ServerComponentDeserializer serverComponentDeserializer, + ICircuitPersistenceProvider circuitPersistenceProvider, + IDataProtectionProvider dataProtectionProvider) : IPersistentComponentStateStore +{ + private PersistedCircuitState? _persistedCircuitState; + + public async Task PauseCircuitAsync(CircuitHost circuit, CancellationToken cancellation = default) + { + var renderer = circuit.Renderer; + using var subscription = persistenceManager.State.RegisterOnPersisting(() => PersistRootComponents(renderer)); + await persistenceManager.PersistStateAsync(this, renderer); + + await circuitPersistenceProvider.PersistCircuitAsync( + circuit.CircuitId, + _persistedCircuitState, + cancellation); + } + + private Task PersistRootComponents(RemoteRenderer renderer) + { + var persistedComponents = new Dictionary(); + var components = renderer.GetOrCreateWebRootComponentManager().GetRootComponents(); + var invocation = new ServerComponentInvocationSequence(); + foreach (var (id, componentKey, (componentType, parameters)) in components) + { + var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, false, componentKey); + serverComponentSerializer.SerializeInvocation(ref marker, invocation, componentType, parameters); + persistedComponents.Add(id, marker); + } + + persistenceManager.State.PersistAsJson(typeof(CircuitPersistenceManager).FullName, persistedComponents); + + return Task.CompletedTask; + } + + public async Task ResumeCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default) + { + return await circuitPersistenceProvider.RestoreCircuitAsync(circuitId, cancellation); + } + + private struct PersistedComponentDescriptor + { + public int SsrComponentId { get; set; } + public ComponentMarkerKey? Key { get; set; } + public ComponentMarker Marker { get; set; } + } + + // This store only support serializing the state + Task> IPersistentComponentStateStore.GetPersistedStateAsync() => throw new NotImplementedException(); + + // During the persisting phase the state is captured into a Dictionary, our implementation registers + // a callback so that it can run at the same time as the other components' state is persisted. + // We then are called to save the persisted state, at which point, we extract the component records + // and store them separately from the other state. + Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary state) + { + var dictionary = new Dictionary(state.Count - 1); + byte[] rootComponentMarkers = null; + foreach (var (key, value) in state) + { + if (key == typeof(CircuitPersistenceManager).FullName) + { + rootComponentMarkers = value; + } + else + { + dictionary[key] = value; + } + } + + _persistedCircuitState = new PersistedCircuitState + { + ApplicationState = dictionary, + RootComponents = rootComponentMarkers + }; + + return Task.CompletedTask; + } + + internal PersistedCircuitState FromProtectedState(string rootComponents, string applicationState) => throw new NotImplementedException(); + + // We are going to construct a RootComponentOperationBatch but we are going to replace the descriptors from the client with the + // descriptors that we have persisted when pausing the circuit. + // The way pausing and resuming works is that when the client starts the resume process, it 'simulates' that an SSR has happened and + // queues and 'Add' operation for each server-side component that is on the document. + // That ends up calling UpdateRootComponents with the old descriptors and no application state. + // On the server side, we replace the descriptors with the ones that we have persisted. We can't use the original descriptors because + // those have a lifetime of ~ 5 minutes, after which we are not able to unprotect them anymore. + internal RootComponentOperationBatch ToRootComponentOperationBatch(byte[] rootComponents, string serializedComponentOperations) + { + // Deserialize the existing batch the client has sent but ignore the markers + if (!serverComponentDeserializer.TryDeserializeRootComponentOperations( + serializedComponentOperations, + out var result, + deserializeMarkers: false)) + { + return null; + } + + var data = JsonSerializer.Deserialize( + rootComponents, + CircuitPersistenceManagerSerializerContext.Default.DictionaryInt32ComponentMarker); + + // Ensure that all operations in the batch are `Add` operations. + for (var i = 0; i < result.Operations.Length; i++) + { + var operation = result.Operations[i]; + if (operation.Type != RootComponentOperationType.Add) + { + return null; + } + + // Retrieve the marker from the persisted root components, replace it and deserialize the descriptor + if (!data.TryGetValue(operation.SsrComponentId, out var marker)) + { + return null; + } + operation.Marker = marker; + + if (!serverComponentDeserializer.TryDeserializeWebRootComponentDescriptor(operation.Marker.Value, out var descriptor)) + { + return null; + } + + operation.Descriptor = descriptor; + } + + return result; + } + + internal (string rootComponents, string applicationState) ToProtectedState(PersistedCircuitState state) => throw new NotImplementedException(); + + internal ProtectedPrerenderComponentApplicationStore ToComponentApplicationStore(Dictionary applicationState) + { + return new ProtectedPrerenderComponentApplicationStore(applicationState, dataProtectionProvider); + } + + [JsonSerializable(typeof(Dictionary))] + internal partial class CircuitPersistenceManagerSerializerContext : JsonSerializerContext + { + } +} diff --git a/src/Components/Server/src/Circuits/CircuitRegistry.cs b/src/Components/Server/src/Circuits/CircuitRegistry.cs index f686011da2a9..c5b000cd4e62 100644 --- a/src/Components/Server/src/Circuits/CircuitRegistry.cs +++ b/src/Components/Server/src/Circuits/CircuitRegistry.cs @@ -41,16 +41,19 @@ internal partial class CircuitRegistry private readonly CircuitOptions _options; private readonly ILogger _logger; private readonly CircuitIdFactory _circuitIdFactory; + private readonly CircuitPersistenceManager _circuitPersistenceManager; private readonly PostEvictionCallbackRegistration _postEvictionCallback; public CircuitRegistry( IOptions options, ILogger logger, - CircuitIdFactory CircuitHostFactory) + CircuitIdFactory CircuitHostFactory, + CircuitPersistenceManager circuitPersistenceManager) { _options = options.Value; _logger = logger; _circuitIdFactory = CircuitHostFactory; + _circuitPersistenceManager = circuitPersistenceManager; ConnectedCircuits = new ConcurrentDictionary(); DisconnectedCircuits = new MemoryCache(new MemoryCacheOptions @@ -264,8 +267,8 @@ protected virtual void OnEntryEvicted(object key, object value, EvictionReason r case EvictionReason.Capacity: // Kick off the dispose in the background. var disconnectedEntry = (DisconnectedCircuitEntry)value; - Log.CircuitEvicted(_logger, disconnectedEntry.CircuitHost.CircuitId, reason); - _ = DisposeCircuitEntry(disconnectedEntry); + Log.CircuitEvicted(_logger, disconnectedEntry.CircuitHost.CircuitId, reason); + _ = PauseAndDisposeCircuitEntry(disconnectedEntry); break; case EvictionReason.Removed: @@ -278,12 +281,13 @@ protected virtual void OnEntryEvicted(object key, object value, EvictionReason r } } - private async Task DisposeCircuitEntry(DisconnectedCircuitEntry entry) + private async Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry) { DisposeTokenSource(entry); try { + await _circuitPersistenceManager.PauseCircuitAsync(entry.CircuitHost); entry.CircuitHost.UnhandledException -= CircuitHost_UnhandledException; await entry.CircuitHost.DisposeAsync(); } diff --git a/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs b/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs new file mode 100644 index 000000000000..8e38c23184c8 --- /dev/null +++ b/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs @@ -0,0 +1,168 @@ +// 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.Extensions.Caching.Memory; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +// Default implmentation of ICircuitPersistenceProvider that uses an in-memory cache +internal sealed partial class DefaultPersistedCircuitCache : ICircuitPersistenceProvider +{ + private readonly Lock _lock = new(); + private readonly CircuitOptions _options; + private readonly MemoryCache _persistedCircuits; + private readonly Task _noMatch = Task.FromResult(null); + private readonly PostEvictionCallbackRegistration _postEvictionCallback; + private readonly ILogger _logger; + + public DefaultPersistedCircuitCache( + ISystemClock clock, + ILogger logger, + IOptions options) + { + _options = options.Value; + _persistedCircuits = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = _options.PersistedCircuitMaxRetained, + Clock = clock + }); + + _postEvictionCallback = new PostEvictionCallbackRegistration + { + EvictionCallback = OnEntryEvicted + }; + + _logger = logger; + } + + public Task PersistCircuitAsync(CircuitId circuitId, PersistedCircuitState persistedCircuitState, CancellationToken cancellation = default) + { + Log.CircuitPauseStarted(_logger, circuitId); + + lock (_lock) + { + PersistCore(circuitId, persistedCircuitState); + } + + return Task.CompletedTask; + } + + private void PersistCore(CircuitId circuitId, PersistedCircuitState persistedCircuitState) + { + var cancellationTokenSource = new CancellationTokenSource(_options.PersistedCircuitInMemoryRetentionPeriod); + var options = new MemoryCacheEntryOptions + { + Size = 1, + PostEvictionCallbacks = { _postEvictionCallback }, + ExpirationTokens = { new CancellationChangeToken(cancellationTokenSource.Token) }, + }; + + var persistedCircuitEntry = new PersistedCircuitEntry + { + State = persistedCircuitState, + TokenSource = cancellationTokenSource + }; + + _persistedCircuits.Set(circuitId.Secret, persistedCircuitEntry, options); + } + + private void OnEntryEvicted(object key, object value, EvictionReason reason, object state) + { + switch (reason) + { + case EvictionReason.Expired: + case EvictionReason.TokenExpired: + // Happens after the circuit state times out, this is triggered by the CancellationTokenSource we register + // with the entry, which is what controls the expiration + case EvictionReason.Capacity: + // Happens when the cache is full + var persistedCircuitEntry = (PersistedCircuitEntry)value; + Log.CircuitStateEvicted(_logger, persistedCircuitEntry.CircuitId, reason); + break; + + case EvictionReason.Removed: + // Happens when the entry is explicitly removed as part of resuming a circuit. + return; + default: + Debug.Fail($"Unexpected {nameof(EvictionReason)} {reason}"); + break; + } + } + + public Task RestoreCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default) + { + Log.CircuitResumeStarted(_logger, circuitId); + + lock (_lock) + { + var state = RestoreCore(circuitId); + if (state == null) + { + Log.FailedToFindCircuitState(_logger, circuitId); + return _noMatch; + } + + return Task.FromResult(state); + } + } + + private PersistedCircuitState RestoreCore(CircuitId circuitId) + { + if (_persistedCircuits.TryGetValue(circuitId.Secret, out var value) && value is PersistedCircuitEntry entry) + { + DisposeTokenSource(entry); + _persistedCircuits.Remove(circuitId.Secret); + Log.CircuitStateFound(_logger, circuitId); + return entry.State; + } + + return null; + } + + private void DisposeTokenSource(PersistedCircuitEntry entry) + { + try + { + entry.TokenSource.Dispose(); + } + catch (Exception ex) + { + Log.ExceptionDisposingTokenSource(_logger, ex); + } + } + + private class PersistedCircuitEntry + { + public PersistedCircuitState State { get; set; } + + public CancellationTokenSource TokenSource { get; set; } + + public CircuitId CircuitId { get; set; } + } + + private static partial class Log + { + [LoggerMessage(101, LogLevel.Debug, "Circuit state evicted for circuit {CircuitId} due to {Reason}", EventName = "CircuitStateEvicted")] + public static partial void CircuitStateEvicted(ILogger logger, CircuitId circuitId, EvictionReason reason); + + [LoggerMessage(102, LogLevel.Debug, "Resuming circuit with ID {CircuitId}", EventName = "CircuitResumeStarted")] + public static partial void CircuitResumeStarted(ILogger logger, CircuitId circuitId); + + [LoggerMessage(103, LogLevel.Debug, "Failed to find persisted circuit with ID {CircuitId}", EventName = "FailedToFindCircuitState")] + public static partial void FailedToFindCircuitState(ILogger logger, CircuitId circuitId); + + [LoggerMessage(104, LogLevel.Debug, "Circuit state found for circuit {CircuitId}", EventName = "CircuitStateFound")] + public static partial void CircuitStateFound(ILogger logger, CircuitId circuitId); + + [LoggerMessage(105, LogLevel.Error, "An exception occurred while disposing the token source.", EventName = "ExceptionDisposingTokenSource")] + public static partial void ExceptionDisposingTokenSource(ILogger logger, Exception exception); + + [LoggerMessage(106, LogLevel.Debug, "Pausing circuit with ID {CircuitId}", EventName = "CircuitPauseStarted")] + public static partial void CircuitPauseStarted(ILogger logger, CircuitId circuitId); + } +} diff --git a/src/Components/Server/src/Circuits/ICircuitPersistenceProvider.cs b/src/Components/Server/src/Circuits/ICircuitPersistenceProvider.cs new file mode 100644 index 000000000000..3149886347da --- /dev/null +++ b/src/Components/Server/src/Circuits/ICircuitPersistenceProvider.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +// Abstraction to support persisting and restoring circuit state +internal interface ICircuitPersistenceProvider +{ + Task PersistCircuitAsync(CircuitId circuitId, PersistedCircuitState persistedCircuitState, CancellationToken cancellation = default); + + Task RestoreCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default); +} diff --git a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs index 524028a3f98a..22a23552e17f 100644 --- a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs @@ -10,5 +10,5 @@ internal interface IServerComponentDeserializer bool TryDeserializeComponentDescriptorCollection( string serializedComponentRecords, out List descriptors); - bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? operationBatch); + bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? operationBatch, bool deserializeDescriptors = true); } diff --git a/src/Components/Server/src/Circuits/PersistedCircuitState.cs b/src/Components/Server/src/Circuits/PersistedCircuitState.cs new file mode 100644 index 000000000000..d21eea163834 --- /dev/null +++ b/src/Components/Server/src/Circuits/PersistedCircuitState.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +internal class PersistedCircuitState +{ + public Dictionary ApplicationState { get; internal set; } + + public byte[] RootComponents { get; internal set; } +} diff --git a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs index ca292c529da8..eaebd8856968 100644 --- a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs @@ -291,7 +291,10 @@ private bool TryDeserializeServerComponent(ComponentMarker record, out ServerCom return (componentDescriptor, serverComponent); } - public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? result) + public bool TryDeserializeRootComponentOperations( + string serializedComponentOperations, + [NotNullWhen(true)] out RootComponentOperationBatch? result, + bool deserializeMarkers = true) { int[]? seenComponentIdsStorage = null; try @@ -329,6 +332,13 @@ public bool TryDeserializeRootComponentOperations(string serializedComponentOper return false; } + if (!deserializeMarkers) + { + // If we are not deserializing markers, we can skip the rest of the processing. + operation.Descriptor = null; + continue; + } + if (!TryDeserializeWebRootComponentDescriptor(operation.Marker.Value, out var descriptor)) { result = null; diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index 84561349ee48..bbe0e381bc00 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -42,6 +42,7 @@ internal sealed partial class ComponentHub : Hub private readonly ICircuitFactory _circuitFactory; private readonly CircuitIdFactory _circuitIdFactory; private readonly CircuitRegistry _circuitRegistry; + private readonly CircuitPersistenceManager _circuitPersistenceManager; private readonly ICircuitHandleRegistry _circuitHandleRegistry; private readonly ILogger _logger; private readonly ActivityContext _httpContext; @@ -52,6 +53,7 @@ public ComponentHub( ICircuitFactory circuitFactory, CircuitIdFactory circuitIdFactory, CircuitRegistry circuitRegistry, + CircuitPersistenceManager circuitPersistenceProvider, ICircuitHandleRegistry circuitHandleRegistry, ILogger logger) { @@ -60,6 +62,7 @@ public ComponentHub( _circuitFactory = circuitFactory; _circuitIdFactory = circuitIdFactory; _circuitRegistry = circuitRegistry; + _circuitPersistenceManager = circuitPersistenceProvider; _circuitHandleRegistry = circuitHandleRegistry; _logger = logger; _httpContext = ComponentsActivitySource.CaptureHttpContext(); @@ -172,21 +175,35 @@ public async Task UpdateRootComponents(string serializedComponentOperations, str return; } - if (!_serverComponentSerializer.TryDeserializeRootComponentOperations( - serializedComponentOperations, - out var operations)) + RootComponentOperationBatch operations; + ProtectedPrerenderComponentApplicationStore store; + var persistedState = circuitHost.TakePersistedCircuitState(); + if (persistedState != null) { - // There was an error, so kill the circuit. - await _circuitRegistry.TerminateAsync(circuitHost.CircuitId); - await NotifyClientError(Clients.Caller, "The list of component operations is not valid."); - Context.Abort(); + operations = _circuitPersistenceManager.ToRootComponentOperationBatch( + persistedState.RootComponents, + serializedComponentOperations); - return; + store = _circuitPersistenceManager.ToComponentApplicationStore(persistedState.ApplicationState); } + else + { + if (!_serverComponentSerializer.TryDeserializeRootComponentOperations( + serializedComponentOperations, + out operations)) + { + // There was an error, so kill the circuit. + await _circuitRegistry.TerminateAsync(circuitHost.CircuitId); + await NotifyClientError(Clients.Caller, "The list of component operations is not valid."); + Context.Abort(); - var store = !string.IsNullOrEmpty(applicationState) ? - new ProtectedPrerenderComponentApplicationStore(applicationState, _dataProtectionProvider) : - new ProtectedPrerenderComponentApplicationStore(_dataProtectionProvider); + return; + } + + store = !string.IsNullOrEmpty(applicationState) ? + new ProtectedPrerenderComponentApplicationStore(applicationState, _dataProtectionProvider) : + new ProtectedPrerenderComponentApplicationStore(_dataProtectionProvider); + } _ = circuitHost.UpdateRootComponents(operations, store, Context.ConnectionAborted); } @@ -220,6 +237,160 @@ public async ValueTask ConnectCircuit(string circuitIdSecret) return false; } + // This method drives the resumption of a circuit that has been previously paused and ejected out of memory. + // Resuming a circuit is very similar to starting a new circuit. + // We receive an existing circuit ID to look up the existing circuit state. + // We receive the base URI and the URI to perform the same checks that we do during start circuit. + // Upon resuming a circuit ID, its ID changes. This has some ramifications: + // * When a circuit is paused, the old circuit is gone. There's no way to bring it back. + // * Resuming a circuit means to essentially create a new circuit. One that "starts" from where the previous one "paused". + // * When a circuit is "paused" it might be stored either in the browser (the client holds all state) during "graceful pauses" or + // it can be stored in cache storage during "ungraceful pauses". + // * For the circuit to successfully resume, this call needs to succeed (returning a new circuit ID). + // * Retrieving and deleting the state for the old circuit is part of this process + // * Once we retrieve the state, we delete it, and we check that it's no longer there before we try to resume + // the new circuit + // * No other connection can get here while we are inside ResumeCircuit (SignalR only processes one message at a time, and we don't work if you change this setting). + // * In the unlikely event that the connection breaks, there are two things that could happen: + // * If the client was the one providing the circuit state, it could potentially resume elsewhere (for example another server). + // * In that case this circuit won't do anything. We don't consider the circuit fully resumed until we have attached and triggered a render + // into the DOM. If a failure happens before that, we directly discard the new circuit and its state. + // * If the state was stored on the server, then the state is gone after we retrieve it from the cache. Even if a client were to connect to + // two separate server instances (for example, server A, B, where it starts resuming on A, something fails and tries to start resuming on B) + // the state would either be ignored in one case or lost. + // * Two things can happen: + // * Both A and B are somehow able to read the same state. + // * Even if A gets the state, it doesn't complete the "resume" handshake, so its state gets discarded + // and not saved again. + // * B might complete the handshake and then the circuit will resume on B. + // * A deletes the state before B is able to read it. Then "resumption" fails, as the circuit state is gone. + + // On the server we are going to have a public method on Circuit.cs to trigger pausing a circuit from the server + // that returns the root components and application state as strings data-protected by the data protection provider. + // Those can be then passed to this method for resuming the circuit. + public async ValueTask ResumeCircuit( + string circuitIdSecret, + string baseUri, + string uri, + string rootComponents, + string applicationState) + { + // TryParseCircuitId will not throw. + if (!_circuitIdFactory.TryParseCircuitId(circuitIdSecret, out var circuitId)) + { + // Invalid id. + Log.InvalidCircuitId(_logger, circuitIdSecret); + return null; + } + + var circuitHost = _circuitHandleRegistry.GetCircuit(Context.Items, CircuitKey); + if (circuitHost != null) + { + // This is an error condition and an attempt to bind multiple circuits to a single connection. + // We can reject this and terminate the connection. + Log.CircuitAlreadyInitialized(_logger, circuitHost.CircuitId); + await NotifyClientError(Clients.Caller, $"The circuit host '{circuitHost.CircuitId}' has already been initialized."); + Context.Abort(); + return null; + } + + if (baseUri == null || + uri == null || + !Uri.TryCreate(baseUri, UriKind.Absolute, out _) || + !Uri.TryCreate(uri, UriKind.Absolute, out _)) + { + // We do some really minimal validation here to prevent obviously wrong data from getting in + // without duplicating too much logic. + // + // This is an error condition attempting to initialize the circuit in a way that would fail. + // We can reject this and terminate the connection. + Log.InvalidInputData(_logger); + await NotifyClientError(Clients.Caller, "The uris provided are invalid."); + Context.Abort(); + return null; + } + + PersistedCircuitState? persistedCircuitState; + if (string.IsNullOrEmpty(rootComponents) && string.IsNullOrEmpty(applicationState)) + { + persistedCircuitState = await _circuitPersistenceManager.ResumeCircuitAsync(circuitId, Context.ConnectionAborted); + if (persistedCircuitState == null) + { + Log.InvalidInputData(_logger); + await NotifyClientError(Clients.Caller, "The circuit state could not be retrieved. It may have been deleted or expired."); + Context.Abort(); + return null; + } + } + else if (!string.IsNullOrEmpty(rootComponents) || !string.IsNullOrEmpty(applicationState)) + { + Log.InvalidInputData(_logger); + await NotifyClientError( + Clients.Caller, + string.IsNullOrEmpty(rootComponents) ? + "The root components provided are invalid." : + "The application state provided is invalid." + ); + Context.Abort(); + return null; + } + else + { + persistedCircuitState = _circuitPersistenceManager.FromProtectedState(rootComponents, applicationState); + if (persistedCircuitState == null) + { + // If we couldn't deserialize the persisted state, signal that. + Log.InvalidInputData(_logger); + await NotifyClientError(Clients.Caller, "The root components or application state provided are invalid."); + Context.Abort(); + return null; + } + } + + try + { + var circuitClient = new CircuitClientProxy(Clients.Caller, Context.ConnectionId); + var resourceCollection = Context.GetHttpContext().GetEndpoint()?.Metadata.GetMetadata(); + circuitHost = await _circuitFactory.CreateCircuitHostAsync( + [], + circuitClient, + baseUri, + uri, + Context.User, + store: null, + resourceCollection); + + // Fire-and-forget the initialization process, because we can't block the + // SignalR message loop (we'd get a deadlock if any of the initialization + // logic relied on receiving a subsequent message from SignalR), and it will + // take care of its own errors anyway. + _ = circuitHost.InitializeAsync(store: null, _httpContext, Context.ConnectionAborted); + + circuitHost.AttachPersistedState(persistedCircuitState); + + // It's safe to *publish* the circuit now because nothing will be able + // to run inside it until after InitializeAsync completes. + _circuitRegistry.Register(circuitHost); + _circuitHandleRegistry.SetCircuit(Context.Items, CircuitKey, circuitHost); + + // Returning the secret here so the client can reconnect. + // + // Logging the secret and circuit ID here so we can associate them with just logs (if TRACE level is on). + Log.CreatedCircuit(_logger, circuitHost.CircuitId, circuitHost.CircuitId.Secret, Context.ConnectionId); + + return circuitHost.CircuitId.Secret; + } + catch (Exception ex) + { + // If the circuit fails to initialize synchronously we can notify the client immediately + // and shut down the connection. + Log.CircuitInitializationFailed(_logger, ex); + await NotifyClientError(Clients.Caller, "The circuit failed to initialize."); + Context.Abort(); + return null; + } + } + public async ValueTask BeginInvokeDotNetFromJS(string callId, string assemblyName, string methodIdentifier, long dotNetObjectId, string argsJson) { var circuitHost = await GetActiveCircuitAsync(); @@ -372,6 +543,13 @@ private async ValueTask GetActiveCircuitAsync([CallerMemberName] st private static Task NotifyClientError(IClientProxy client, string error) => client.SendAsync("JS.Error", error); + internal class ResumeCircuitResult + { + public string CircuitId { get; set; } + public string ApplicationState { get; set; } + public string Operations { get; set; } + } + private static partial class Log { [LoggerMessage(1, LogLevel.Debug, "Received confirmation for batch {BatchId}", EventName = "ReceivedConfirmationForBatch")] diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index ed27d422aca6..f66329bd651a 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -52,6 +52,8 @@ + + diff --git a/src/Components/Server/test/CircuitDisconnectMiddlewareTest.cs b/src/Components/Server/test/CircuitDisconnectMiddlewareTest.cs index ecf4c858745b..e194c50b7d32 100644 --- a/src/Components/Server/test/CircuitDisconnectMiddlewareTest.cs +++ b/src/Components/Server/test/CircuitDisconnectMiddlewareTest.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Moq; namespace Microsoft.AspNetCore.Components.Server; @@ -23,7 +24,8 @@ public async Task DisconnectMiddleware_OnlyAccepts_PostRequests(string httpMetho var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); var middleware = new CircuitDisconnectMiddleware( NullLogger.Instance, @@ -51,7 +53,8 @@ public async Task Returns400BadRequest_ForInvalidContentTypes(string contentType var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); var middleware = new CircuitDisconnectMiddleware( NullLogger.Instance, @@ -78,7 +81,8 @@ public async Task Returns400BadRequest_IfNoCircuitIdOnForm() var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); var middleware = new CircuitDisconnectMiddleware( NullLogger.Instance, @@ -105,7 +109,8 @@ public async Task Returns400BadRequest_InvalidCircuitId() var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); var middleware = new CircuitDisconnectMiddleware( NullLogger.Instance, @@ -138,7 +143,8 @@ public async Task Returns200OK_NonExistingCircuit() var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); var middleware = new CircuitDisconnectMiddleware( NullLogger.Instance, @@ -173,7 +179,8 @@ public async Task GracefullyTerminates_ConnectedCircuit() var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); registry.Register(testCircuitHost); @@ -210,7 +217,8 @@ public async Task GracefullyTerminates_DisconnectedCircuit() var registry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, + Mock.Of()); registry.Register(circuitHost); await registry.DisconnectAsync(circuitHost, "1234"); diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index b68bdf8286fa..9538b66d1243 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -851,7 +851,7 @@ public bool TryDeserializeComponentDescriptorCollection(string serializedCompone return true; } - public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out RootComponentOperationBatch operationBatch) + public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out RootComponentOperationBatch operationBatch, bool deserializeDescriptors = true) { operationBatch = default; return true; diff --git a/src/Components/Server/test/Circuits/CircuitRegistryTest.cs b/src/Components/Server/test/Circuits/CircuitRegistryTest.cs index 9a30f3eca81b..0ea404eb94ee 100644 --- a/src/Components/Server/test/Circuits/CircuitRegistryTest.cs +++ b/src/Components/Server/test/Circuits/CircuitRegistryTest.cs @@ -354,7 +354,7 @@ public async Task ReconnectBeforeTimeoutDoesNotGetEntryToBeEvicted() private class TestCircuitRegistry : CircuitRegistry { public TestCircuitRegistry(CircuitIdFactory factory, CircuitOptions circuitOptions = null) - : base(Options.Create(circuitOptions ?? new CircuitOptions()), NullLogger.Instance, factory) + : base(Options.Create(circuitOptions ?? new CircuitOptions()), NullLogger.Instance, factory, Mock.Of()) { } @@ -395,6 +395,6 @@ private static CircuitRegistry CreateRegistry(CircuitIdFactory factory = null) return new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - factory ?? TestCircuitIdFactory.CreateTestFactory()); + factory ?? TestCircuitIdFactory.CreateTestFactory(), Mock.Of()); } } diff --git a/src/Components/Server/test/Circuits/ComponentHubTest.cs b/src/Components/Server/test/Circuits/ComponentHubTest.cs index 9d98dc1bf7c2..504fa54f04a8 100644 --- a/src/Components/Server/test/Circuits/ComponentHubTest.cs +++ b/src/Components/Server/test/Circuits/ComponentHubTest.cs @@ -112,7 +112,7 @@ private static (Mock, ComponentHub) InitializeComponentHub() var circuitRegistry = new CircuitRegistry( Options.Create(new CircuitOptions()), NullLogger.Instance, - circuitIdFactory); + circuitIdFactory, Mock.Of()); var serializer = new TestServerComponentDeserializer(); var circuitHandleRegistry = new TestCircuitHandleRegistry(); var hub = new ComponentHub( @@ -121,6 +121,7 @@ private static (Mock, ComponentHub) InitializeComponentHub() circuitFactory: circuitFactory, circuitIdFactory: circuitIdFactory, circuitRegistry: circuitRegistry, + circuitPersistenceProvider: Mock.Of(), circuitHandleRegistry: circuitHandleRegistry, logger: NullLogger.Instance); @@ -178,7 +179,7 @@ public bool TryDeserializeComponentDescriptorCollection(string serializedCompone return true; } - public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out RootComponentOperationBatch operationsWithDescriptors) + public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out RootComponentOperationBatch operationsWithDescriptors, bool deserializeDescriptors = true) { operationsWithDescriptors = default; return true; diff --git a/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj b/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj index db46700aed1b..341fa72218a0 100644 --- a/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj +++ b/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj @@ -22,8 +22,6 @@ - - diff --git a/src/Components/Shared/src/WebRootComponentManager.cs b/src/Components/Shared/src/WebRootComponentManager.cs index 8720cdd11105..5b62fb51fed5 100644 --- a/src/Components/Shared/src/WebRootComponentManager.cs +++ b/src/Components/Shared/src/WebRootComponentManager.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Numerics; using static Microsoft.AspNetCore.Internal.LinkerFlags; #if COMPONENTS_SERVER @@ -83,6 +84,16 @@ private WebRootComponent GetRequiredWebRootComponent(int ssrComponentId) return component; } +#if COMPONENTS_SERVER + internal IEnumerable<(int id, ComponentMarkerKey key, (Type componentType, ParameterView parameters))> GetRootComponents() + { + foreach (var (id, (key, type, parameters)) in _webRootComponents) + { + yield return (id, key, (type, parameters)); + } + } +#endif + private sealed class WebRootComponent { [DynamicallyAccessedMembers(Component)] @@ -125,6 +136,18 @@ private WebRootComponent( _latestParameters = initialParameters; } +#if COMPONENTS_SERVER + public void Deconstruct( + out ComponentMarkerKey key, + out Type componentType, + out ParameterView parameters) + { + key = _key; + componentType = _componentType; + parameters = _latestParameters.Parameters; + } +#endif + public Task UpdateAsync( Renderer renderer, [DynamicallyAccessedMembers(Component)] Type newComponentType, diff --git a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs index 6025dc48f205..adc599661dd9 100644 --- a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs @@ -21,6 +21,12 @@ public ProtectedPrerenderComponentApplicationStore(string existingState, IDataPr DeserializeState(_protector.Unprotect(Convert.FromBase64String(existingState))); } + public ProtectedPrerenderComponentApplicationStore(Dictionary deserializedState, IDataProtectionProvider dataProtectionProvider) + { + CreateProtector(dataProtectionProvider); + ExistingState = deserializedState; + } + protected override byte[] SerializeState(IReadOnlyDictionary state) { var bytes = base.SerializeState(state); diff --git a/src/Components/Endpoints/src/DependencyInjection/ServerComponentInvocationSequence.cs b/src/Shared/Components/ServerComponentInvocationSequence.cs similarity index 100% rename from src/Components/Endpoints/src/DependencyInjection/ServerComponentInvocationSequence.cs rename to src/Shared/Components/ServerComponentInvocationSequence.cs diff --git a/src/Components/Endpoints/src/DependencyInjection/ServerComponentSerializer.cs b/src/Shared/Components/ServerComponentSerializer.cs similarity index 100% rename from src/Components/Endpoints/src/DependencyInjection/ServerComponentSerializer.cs rename to src/Shared/Components/ServerComponentSerializer.cs From d7a1fdae323764d7b22afb8058de56b5fd7e93a8 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 27 May 2025 17:52:59 +0200 Subject: [PATCH 02/23] Added sample --- .../Samples/BlazorUnitedApp/App.razor | 29 +++++++++++++++++-- .../Data/WeatherForecastService.cs | 7 +++-- .../BlazorUnitedApp/Pages/FetchData.razor | 11 ++++--- .../Samples/BlazorUnitedApp/Program.cs | 6 +++- .../Properties/launchSettings.json | 1 + .../BlazorUnitedApp/Shared/NavMenu.razor | 10 +++++++ 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/Components/Samples/BlazorUnitedApp/App.razor b/src/Components/Samples/BlazorUnitedApp/App.razor index e04f7fc9e8e4..6a2e7fcfbbc0 100644 --- a/src/Components/Samples/BlazorUnitedApp/App.razor +++ b/src/Components/Samples/BlazorUnitedApp/App.razor @@ -14,7 +14,32 @@ - - + + + diff --git a/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs b/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs index 7a997104f02c..65ab41606876 100644 --- a/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs +++ b/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs @@ -10,13 +10,14 @@ public class WeatherForecastService "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - public Task GetForecastAsync(DateOnly startDate) + public async Task GetForecastAsync(DateOnly startDate) { - return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast + await Task.Delay(1000); + return [.. Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = startDate.AddDays(index), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }).ToArray()); + })]; } } diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor b/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor index 46af23cf37fb..ad027c75499c 100644 --- a/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor +++ b/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor @@ -6,9 +6,11 @@

Weather forecast

+@Guid.NewGuid().ToString() +

This component demonstrates fetching data from a service.

-@if (forecasts == null) +@if (Forecasts == null) {

Loading...

} @@ -24,7 +26,7 @@ else - @foreach (var forecast in forecasts) + @foreach (var forecast in Forecasts) { @forecast.Date.ToShortDateString() @@ -38,10 +40,11 @@ else } @code { - private WeatherForecast[]? forecasts; + + [SupplyParameterFromPersistentComponentState] public WeatherForecast[]? Forecasts { get; set; } protected override async Task OnInitializedAsync() { - forecasts = await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now)); + Forecasts ??= await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now)); } } diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 6492f3fb3e50..b63b2198811f 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -9,7 +9,11 @@ // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents(o => + { + o.DisconnectedCircuitMaxRetained = 5; + o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromSeconds(2); + }); builder.Services.AddSingleton(); diff --git a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json index 265de0cd64ad..d37d67e969e2 100644 --- a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json +++ b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json @@ -22,6 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, + "launchUrl": "fetchdata", "applicationUrl": "https://localhost:7247;http://localhost:5265", //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { diff --git a/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor b/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor index 3801dfc18932..85df08c17138 100644 --- a/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor +++ b/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor @@ -29,6 +29,16 @@ Web assembly + + From 56c401aa939ec1043dafc4f2aaeff4dcf5e3c19c Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 27 May 2025 19:10:46 +0200 Subject: [PATCH 03/23] Fix DI issues --- .../src/Circuits/CircuitPersistenceManager.cs | 104 +++++++++--------- .../Circuits/DefaultPersistedCircuitCache.cs | 4 +- .../Circuits/IServerComponentDeserializer.cs | 2 + src/Components/Server/src/ComponentHub.cs | 1 + .../ComponentServiceCollectionExtensions.cs | 6 + 5 files changed, 65 insertions(+), 52 deletions(-) diff --git a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs index 59758f2b460b..569857f18245 100644 --- a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs +++ b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs @@ -5,32 +5,35 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Components.Server.Circuits; internal partial class CircuitPersistenceManager( - ComponentStatePersistenceManager persistenceManager, ServerComponentSerializer serverComponentSerializer, - ServerComponentDeserializer serverComponentDeserializer, ICircuitPersistenceProvider circuitPersistenceProvider, - IDataProtectionProvider dataProtectionProvider) : IPersistentComponentStateStore + IDataProtectionProvider dataProtectionProvider) { - private PersistedCircuitState? _persistedCircuitState; public async Task PauseCircuitAsync(CircuitHost circuit, CancellationToken cancellation = default) { var renderer = circuit.Renderer; - using var subscription = persistenceManager.State.RegisterOnPersisting(() => PersistRootComponents(renderer)); - await persistenceManager.PersistStateAsync(this, renderer); + var persistenceManager = circuit.Services.GetRequiredService(); + using var subscription = persistenceManager.State.RegisterOnPersisting( + () => PersistRootComponents(renderer, persistenceManager.State), + RenderMode.InteractiveServer); + var store = new CircuitPersistenceManagerStore(); + await persistenceManager.PersistStateAsync(store, renderer); await circuitPersistenceProvider.PersistCircuitAsync( circuit.CircuitId, - _persistedCircuitState, + store.PersistedCircuitState, cancellation); } - private Task PersistRootComponents(RemoteRenderer renderer) + private Task PersistRootComponents(RemoteRenderer renderer, PersistentComponentState state) { var persistedComponents = new Dictionary(); var components = renderer.GetOrCreateWebRootComponentManager().GetRootComponents(); @@ -42,7 +45,7 @@ private Task PersistRootComponents(RemoteRenderer renderer) persistedComponents.Add(id, marker); } - persistenceManager.State.PersistAsJson(typeof(CircuitPersistenceManager).FullName, persistedComponents); + state.PersistAsJson(typeof(CircuitPersistenceManager).FullName, persistedComponents); return Task.CompletedTask; } @@ -52,45 +55,6 @@ public async Task ResumeCircuitAsync(CircuitId circuitId, return await circuitPersistenceProvider.RestoreCircuitAsync(circuitId, cancellation); } - private struct PersistedComponentDescriptor - { - public int SsrComponentId { get; set; } - public ComponentMarkerKey? Key { get; set; } - public ComponentMarker Marker { get; set; } - } - - // This store only support serializing the state - Task> IPersistentComponentStateStore.GetPersistedStateAsync() => throw new NotImplementedException(); - - // During the persisting phase the state is captured into a Dictionary, our implementation registers - // a callback so that it can run at the same time as the other components' state is persisted. - // We then are called to save the persisted state, at which point, we extract the component records - // and store them separately from the other state. - Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary state) - { - var dictionary = new Dictionary(state.Count - 1); - byte[] rootComponentMarkers = null; - foreach (var (key, value) in state) - { - if (key == typeof(CircuitPersistenceManager).FullName) - { - rootComponentMarkers = value; - } - else - { - dictionary[key] = value; - } - } - - _persistedCircuitState = new PersistedCircuitState - { - ApplicationState = dictionary, - RootComponents = rootComponentMarkers - }; - - return Task.CompletedTask; - } - internal PersistedCircuitState FromProtectedState(string rootComponents, string applicationState) => throw new NotImplementedException(); // We are going to construct a RootComponentOperationBatch but we are going to replace the descriptors from the client with the @@ -100,13 +64,16 @@ Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary> IPersistentComponentStateStore.GetPersistedStateAsync() => throw new NotImplementedException(); + + // During the persisting phase the state is captured into a Dictionary, our implementation registers + // a callback so that it can run at the same time as the other components' state is persisted. + // We then are called to save the persisted state, at which point, we extract the component records + // and store them separately from the other state. + Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary state) + { + var dictionary = new Dictionary(state.Count - 1); + byte[] rootComponentMarkers = null; + foreach (var (key, value) in state) + { + if (key == typeof(CircuitPersistenceManager).FullName) + { + rootComponentMarkers = value; + } + else + { + dictionary[key] = value; + } + } + + PersistedCircuitState = new PersistedCircuitState + { + ApplicationState = dictionary, + RootComponents = rootComponentMarkers + }; + + return Task.CompletedTask; + } + } + [JsonSerializable(typeof(Dictionary))] internal partial class CircuitPersistenceManagerSerializerContext : JsonSerializerContext { diff --git a/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs b/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs index 8e38c23184c8..0e7ae00c034a 100644 --- a/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs +++ b/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; // Default implmentation of ICircuitPersistenceProvider that uses an in-memory cache -internal sealed partial class DefaultPersistedCircuitCache : ICircuitPersistenceProvider +internal sealed partial class DefaultInMemoryCircuitPersistenceProvider : ICircuitPersistenceProvider { private readonly Lock _lock = new(); private readonly CircuitOptions _options; @@ -20,7 +20,7 @@ internal sealed partial class DefaultPersistedCircuitCache : ICircuitPersistence private readonly PostEvictionCallbackRegistration _postEvictionCallback; private readonly ILogger _logger; - public DefaultPersistedCircuitCache( + public DefaultInMemoryCircuitPersistenceProvider( ISystemClock clock, ILogger logger, IOptions options) diff --git a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs index 22a23552e17f..003bd0de81d9 100644 --- a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs @@ -11,4 +11,6 @@ bool TryDeserializeComponentDescriptorCollection( string serializedComponentRecords, out List descriptors); bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? operationBatch, bool deserializeDescriptors = true); + + public bool TryDeserializeWebRootComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out WebRootComponentDescriptor? result); } diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index bbe0e381bc00..7f4faa0837d7 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -181,6 +181,7 @@ public async Task UpdateRootComponents(string serializedComponentOperations, str if (persistedState != null) { operations = _circuitPersistenceManager.ToRootComponentOperationBatch( + _serverComponentSerializer, persistedState.RootComponents, serializedComponentOperations); diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index 4c6eb34d27f6..a93fc5a9da95 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Server; @@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.SignalR.Protocol; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -68,6 +70,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -75,7 +78,10 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti services.TryAddScoped(s => s.GetRequiredService().Circuit); services.TryAddScoped(); + services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); // Standard blazor hosting services implementations // From 599639a55520170fe4db2b5ceba20661ffc93aef Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 27 May 2025 19:11:22 +0200 Subject: [PATCH 04/23] Temporarily add JS bits --- .../src/Platform/Circuits/CircuitManager.ts | 49 +++++++++++++++++-- .../Web.JS/src/Rendering/BrowserRenderer.ts | 21 +++++++- .../src/Rendering/Events/EventDelegator.ts | 20 ++++++++ .../Web.JS/src/Rendering/LogicalElements.ts | 10 ++++ .../Web.JS/src/Rendering/Renderer.ts | 8 +++ .../src/Services/RootComponentManager.ts | 2 + .../src/Services/WebRootComponentManager.ts | 21 ++++++-- 7 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index 7788f9d4358f..49a5af5e3621 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -3,7 +3,7 @@ import { internalFunctions as navigationManagerFunctions } from '../../Services/NavigationManager'; import { toLogicalRootCommentElement, LogicalElement, toLogicalElement } from '../../Rendering/LogicalElements'; -import { ServerComponentDescriptor, descriptorToMarker } from '../../Services/ComponentDescriptorDiscovery'; +import { ComponentDescriptor, ComponentMarker, ServerComponentDescriptor, descriptorToMarker } from '../../Services/ComponentDescriptorDiscovery'; import { HttpTransportType, HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr'; import { getAndRemovePendingRootComponentContainer } from '../../Rendering/JSRootComponents'; import { RootComponentManager } from '../../Services/RootComponentManager'; @@ -18,6 +18,7 @@ import { Blazor } from '../../GlobalExports'; import { showErrorNotification } from '../../BootErrors'; import { attachWebRendererInterop, detachWebRendererInterop } from '../../Rendering/WebRendererInteropMethods'; import { sendJSDataStream } from './CircuitStreamingInterop'; +import { RootComponentInfo } from '../../Services/WebRootComponentManager'; export class CircuitManager implements DotNet.DotNetCallDispatcher { private readonly _componentManager: RootComponentManager; @@ -28,7 +29,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { private readonly _logger: ConsoleLogger; - private readonly _renderQueue: RenderQueue; + private _renderQueue: RenderQueue; private readonly _dispatcher: DotNet.ICallDispatcher; @@ -106,7 +107,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { } for (const handler of this._options.circuitHandlers) { - if (handler.onCircuitOpened){ + if (handler.onCircuitOpened) { handler.onCircuitOpened(); } } @@ -224,7 +225,47 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { } if (!await this._connection!.invoke('ConnectCircuit', this._circuitId)) { - return false; + detachWebRendererInterop(WebRendererId.Server); + this._interopMethodsForReconnection = undefined; + const resume = await this._connection!.invoke( + 'ResumeCircuit', + this._circuitId, + navigationManagerFunctions.getBaseURI(), + navigationManagerFunctions.getLocationHref(), + '[]', + '' + ); + if (!resume) { + return false; + } + + const { circuitId, batch, state } = JSON.parse(resume); + const parsedBatch = JSON.parse(batch); + const operations = parsedBatch.operations; + const infos: RootComponentInfo[] = []; + + for (let i = 0; i < operations.length; i++) { + const operation = operations[i]; + if (operation.type === 'Add') { + const descriptor = this._componentManager.resolveRootComponent(operation.ssrComponentId); + console.log(descriptor); + const rootComponent = { + descriptor: { + ...descriptor, + ...operation.marker, + }, + ssrComponentId: operation.ssrComponentId, + }; + console.log(rootComponent); + infos.push(rootComponent); + } + + this._circuitId = circuitId; + this._applicationState = state; + this._firstUpdate = true; + this._renderQueue = new RenderQueue(this._logger); + this._componentManager.onComponentReset?.(infos); + } } this._options.reconnectionHandler!.onConnectionUp(); diff --git a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts index 7496dd552286..e012560692a8 100644 --- a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts @@ -3,7 +3,7 @@ import { RenderBatch, ArrayBuilderSegment, RenderTreeEdit, RenderTreeFrame, EditType, FrameType, ArrayValues } from './RenderBatch/RenderBatch'; import { EventDelegator } from './Events/EventDelegator'; -import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, permuteLogicalChildren, getClosestDomElement, emptyLogicalElement, getLogicalChildrenArray } from './LogicalElements'; +import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, permuteLogicalChildren, getClosestDomElement, emptyLogicalElement, getLogicalChildrenArray, depthFirstNodeTreeTraversal } from './LogicalElements'; import { applyCaptureIdToElement } from './ElementReferenceCapture'; import { attachToEventDelegator as attachNavigationManagerToEventDelegator } from '../Services/NavigationManager'; import { applyAnyDeferredValue, tryApplySpecialProperty } from './DomSpecialPropertyUtil'; @@ -55,6 +55,25 @@ export class BrowserRenderer { elementsToClearOnRootComponentRender.add(element); } + public detachRootComponentFromLogicalElement(componentId: number): void { + const element = this.childComponentLocations[componentId]; + if (!element) { + throw new Error(`Root component '${componentId}' is not currently attached to any element`); + } + + // If the element is a root component, we remove the association with the component ID + // and mark it as no longer being an interactive root component. + markAsInteractiveRootComponentElement(element, false); + delete this.childComponentLocations[componentId]; + + for (const childNode of depthFirstNodeTreeTraversal(element)) { + if (childNode instanceof Element){ + this.eventDelegator.removeListenersForElement(childNode as Element); + } + emptyLogicalElement(childNode as LogicalElement); + } + } + public updateComponent(batch: RenderBatch, componentId: number, edits: ArrayBuilderSegment, referenceFrames: ArrayValues): void { const element = this.childComponentLocations[componentId]; if (!element) { diff --git a/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts b/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts index c64b38ee952d..0edd38880775 100644 --- a/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts +++ b/src/Components/Web.JS/src/Rendering/Events/EventDelegator.ts @@ -113,6 +113,18 @@ export class EventDelegator { } } + public removeListenersForElement(element: Element): void { + // This method gets called whenever the .NET-side code reports that a certain element + // has been disposed. We remove all event handlers for that element. + const infosForElement = this.getEventHandlerInfosForElement(element, false); + if (infosForElement) { + for (const handlerInfo of infosForElement.enumerateHandlers()) { + this.eventInfoStore.remove(handlerInfo.eventHandlerId); + } + delete element[this.eventsCollectionKey]; + } + } + public notifyAfterClick(callback: (event: MouseEvent) => void): void { // This is extremely special-case. It's needed so that navigation link click interception // can be sure to run *after* our synthetic bubbling process. If a need arises, we can @@ -326,6 +338,14 @@ class EventHandlerInfosForElement { private stopPropagationFlags: { [eventName: string]: boolean } | null = null; + public *enumerateHandlers() : IterableIterator { + for (const eventName in this.handlers) { + if (Object.prototype.hasOwnProperty.call(this.handlers, eventName)) { + yield this.handlers[eventName]; + } + } + } + public getHandler(eventName: string): EventHandlerInfo | null { return Object.prototype.hasOwnProperty.call(this.handlers, eventName) ? this.handlers[eventName] : null; } diff --git a/src/Components/Web.JS/src/Rendering/LogicalElements.ts b/src/Components/Web.JS/src/Rendering/LogicalElements.ts index a6e904febf04..d963499c5a75 100644 --- a/src/Components/Web.JS/src/Rendering/LogicalElements.ts +++ b/src/Components/Web.JS/src/Rendering/LogicalElements.ts @@ -250,6 +250,16 @@ export function isLogicalElement(element: Node): boolean { return logicalChildrenPropname in element; } +// This function returns all the descendants of the logical element before yielding the element +// itself. +export function *depthFirstNodeTreeTraversal(element: LogicalElement): Iterable { + const children = getLogicalChildrenArray(element); + for (const child of children) { + yield* depthFirstNodeTreeTraversal(child); + } + yield element; +} + export function permuteLogicalChildren(parent: LogicalElement, permutationList: PermutationListEntry[]): void { // The permutationList must represent a valid permutation, i.e., the list of 'from' indices // is distinct, and the list of 'to' indices is a permutation of it. The algorithm here diff --git a/src/Components/Web.JS/src/Rendering/Renderer.ts b/src/Components/Web.JS/src/Rendering/Renderer.ts index a5619de92702..aed68cc98afc 100644 --- a/src/Components/Web.JS/src/Rendering/Renderer.ts +++ b/src/Components/Web.JS/src/Rendering/Renderer.ts @@ -47,6 +47,14 @@ export function attachRootComponentToElement(elementSelector: string, componentI attachRootComponentToLogicalElement(browserRendererId, toLogicalElement(element, /* allow existing contents */ true), componentId, appendContent); } +export function detachRootComponent(browserRendererId: number, componentId: number): void { + const browserRenderer = browserRenderers[browserRendererId]; + if (!browserRenderer) { + throw new Error(`There is no browser renderer with ID ${browserRendererId}.`); + } + browserRenderer.detachRootComponentFromLogicalElement(componentId); +} + export function getRendererer(browserRendererId: number): BrowserRenderer | undefined { return browserRenderers[browserRendererId]; } diff --git a/src/Components/Web.JS/src/Services/RootComponentManager.ts b/src/Components/Web.JS/src/Services/RootComponentManager.ts index 17aa2576ac9c..1ccb9d06a55e 100644 --- a/src/Components/Web.JS/src/Services/RootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/RootComponentManager.ts @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. import { ComponentDescriptor } from './ComponentDescriptorDiscovery'; +import { RootComponentInfo } from './WebRootComponentManager'; export interface RootComponentManager { initialComponents: InitialComponentsDescriptorType[]; onAfterRenderBatch?(browserRendererId: number): void; onAfterUpdateRootComponents?(batchId: number): void; + onComponentReset?(descriptors: RootComponentInfo []): void; resolveRootComponent(ssrComponentId: number): ComponentDescriptor; } diff --git a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts index 1c1dbb067db0..8a50971edd58 100644 --- a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { ComponentDescriptor, ComponentMarker, descriptorToMarker, WebAssemblyServerOptions } from './ComponentDescriptorDiscovery'; +import { ComponentDescriptor, ComponentMarker, descriptorToMarker, ServerComponentDescriptor, WebAssemblyServerOptions } from './ComponentDescriptorDiscovery'; import { isRendererAttached, registerRendererAttachedListener } from '../Rendering/WebRendererInteropMethods'; import { WebRendererId } from '../Rendering/WebRendererId'; import { DescriptorHandler } from '../Rendering/DomMerging/DomSync'; @@ -9,7 +9,7 @@ import { disposeCircuit, hasStartedServer, isCircuitAvailable, startCircuit, sta import { hasLoadedWebAssemblyPlatform, hasStartedLoadingWebAssemblyPlatform, hasStartedWebAssembly, isFirstUpdate, loadWebAssemblyPlatformIfNotStarted, resolveInitialUpdate, setWaitForRootComponents, startWebAssembly, updateWebAssemblyRootComponents, waitForBootConfigLoaded } from '../Boot.WebAssembly.Common'; import { MonoConfig } from '@microsoft/dotnet-runtime'; import { RootComponentManager } from './RootComponentManager'; -import { getRendererer } from '../Rendering/Renderer'; +import { detachRootComponent, getRendererer } from '../Rendering/Renderer'; import { isPageLoading } from './NavigationEnhancement'; import { setShouldPreserveContentOnInteractiveComponentDisposal } from '../Rendering/BrowserRenderer'; import { LogicalElement } from '../Rendering/LogicalElements'; @@ -38,7 +38,7 @@ type RootComponentRemoveOperation = { ssrComponentId: number; }; -type RootComponentInfo = { +export type RootComponentInfo = { descriptor: ComponentDescriptor; ssrComponentId: number; assignedRendererId?: WebRendererId; @@ -206,10 +206,10 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent // The following timeout allows us to liberally call this function without // taking the small performance hit from requent repeated calls to // refreshRootComponents. - setTimeout(() => { + queueMicrotask(() => { this._isComponentRefreshPending = false; this.refreshRootComponents(this._rootComponentsBySsrComponentId.values()); - }, 0); + }); } private circuitMayHaveNoRootComponents() { @@ -463,6 +463,17 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent } } } + + public onComponentReset(infos: RootComponentInfo []): void { + for (const info of infos) { + const descriptor = info.descriptor as ServerComponentDescriptor; + const existing = this._rootComponentsBySsrComponentId.get(info.ssrComponentId); + this.unregisterComponent(existing!); + detachRootComponent(existing!.assignedRendererId as number, existing!.ssrComponentId); + this.registerComponent(descriptor); + } + this.rootComponentsMayRequireRefresh(); + } } function isDescriptorInDocument(descriptor: ComponentDescriptor): boolean { From 81e77d966628dcb79b382f25affc88e89fff5cb1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 28 May 2025 16:43:16 +0200 Subject: [PATCH 05/23] Fixes --- .../src/Circuits/CircuitPersistenceManager.cs | 6 ++-- src/Components/Server/src/ComponentHub.cs | 4 +-- .../src/Platform/Circuits/CircuitManager.ts | 35 ++++--------------- .../src/Services/RootComponentManager.ts | 3 +- .../src/Services/WebRootComponentManager.ts | 13 ++++--- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs index 569857f18245..87617ff3af06 100644 --- a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs +++ b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs @@ -64,7 +64,7 @@ public async Task ResumeCircuitAsync(CircuitId circuitId, // That ends up calling UpdateRootComponents with the old descriptors and no application state. // On the server side, we replace the descriptors with the ones that we have persisted. We can't use the original descriptors because // those have a lifetime of ~ 5 minutes, after which we are not able to unprotect them anymore. - internal RootComponentOperationBatch ToRootComponentOperationBatch( + internal static RootComponentOperationBatch ToRootComponentOperationBatch( IServerComponentDeserializer serverComponentDeserializer, byte[] rootComponents, string serializedComponentOperations) @@ -78,9 +78,9 @@ internal RootComponentOperationBatch ToRootComponentOperationBatch( return null; } - var data = JsonSerializer.Deserialize( + var data = JsonSerializer.Deserialize>( rootComponents, - CircuitPersistenceManagerSerializerContext.Default.DictionaryInt32ComponentMarker); + JsonSerializerOptionsProvider.Options); // Ensure that all operations in the batch are `Add` operations. for (var i = 0; i < result.Operations.Length; i++) diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index 7f4faa0837d7..128bfc24e05b 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -180,7 +180,7 @@ public async Task UpdateRootComponents(string serializedComponentOperations, str var persistedState = circuitHost.TakePersistedCircuitState(); if (persistedState != null) { - operations = _circuitPersistenceManager.ToRootComponentOperationBatch( + operations = CircuitPersistenceManager.ToRootComponentOperationBatch( _serverComponentSerializer, persistedState.RootComponents, serializedComponentOperations); @@ -312,7 +312,7 @@ public async ValueTask ResumeCircuit( } PersistedCircuitState? persistedCircuitState; - if (string.IsNullOrEmpty(rootComponents) && string.IsNullOrEmpty(applicationState)) + if (rootComponents == "[]" && string.IsNullOrEmpty(applicationState)) { persistedCircuitState = await _circuitPersistenceManager.ResumeCircuitAsync(circuitId, Context.ConnectionAborted); if (persistedCircuitState == null) diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index 49a5af5e3621..b5c0baf8c288 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -18,7 +18,6 @@ import { Blazor } from '../../GlobalExports'; import { showErrorNotification } from '../../BootErrors'; import { attachWebRendererInterop, detachWebRendererInterop } from '../../Rendering/WebRendererInteropMethods'; import { sendJSDataStream } from './CircuitStreamingInterop'; -import { RootComponentInfo } from '../../Services/WebRootComponentManager'; export class CircuitManager implements DotNet.DotNetCallDispatcher { private readonly _componentManager: RootComponentManager; @@ -225,8 +224,6 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { } if (!await this._connection!.invoke('ConnectCircuit', this._circuitId)) { - detachWebRendererInterop(WebRendererId.Server); - this._interopMethodsForReconnection = undefined; const resume = await this._connection!.invoke( 'ResumeCircuit', this._circuitId, @@ -236,36 +233,16 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { '' ); if (!resume) { + detachWebRendererInterop(WebRendererId.Server); + this._interopMethodsForReconnection = undefined; return false; } - const { circuitId, batch, state } = JSON.parse(resume); - const parsedBatch = JSON.parse(batch); - const operations = parsedBatch.operations; - const infos: RootComponentInfo[] = []; - - for (let i = 0; i < operations.length; i++) { - const operation = operations[i]; - if (operation.type === 'Add') { - const descriptor = this._componentManager.resolveRootComponent(operation.ssrComponentId); - console.log(descriptor); - const rootComponent = { - descriptor: { - ...descriptor, - ...operation.marker, - }, - ssrComponentId: operation.ssrComponentId, - }; - console.log(rootComponent); - infos.push(rootComponent); - } + this._circuitId = resume; + this._renderQueue = new RenderQueue(this._logger); + + this._componentManager.onComponentReset?.(); - this._circuitId = circuitId; - this._applicationState = state; - this._firstUpdate = true; - this._renderQueue = new RenderQueue(this._logger); - this._componentManager.onComponentReset?.(infos); - } } this._options.reconnectionHandler!.onConnectionUp(); diff --git a/src/Components/Web.JS/src/Services/RootComponentManager.ts b/src/Components/Web.JS/src/Services/RootComponentManager.ts index 1ccb9d06a55e..7b9754a1dde6 100644 --- a/src/Components/Web.JS/src/Services/RootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/RootComponentManager.ts @@ -2,12 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. import { ComponentDescriptor } from './ComponentDescriptorDiscovery'; -import { RootComponentInfo } from './WebRootComponentManager'; export interface RootComponentManager { initialComponents: InitialComponentsDescriptorType[]; onAfterRenderBatch?(browserRendererId: number): void; onAfterUpdateRootComponents?(batchId: number): void; - onComponentReset?(descriptors: RootComponentInfo []): void; + onComponentReset?(): void; resolveRootComponent(ssrComponentId: number): ComponentDescriptor; } diff --git a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts index 8a50971edd58..9811e0104ac2 100644 --- a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts @@ -464,14 +464,13 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent } } - public onComponentReset(infos: RootComponentInfo []): void { - for (const info of infos) { - const descriptor = info.descriptor as ServerComponentDescriptor; - const existing = this._rootComponentsBySsrComponentId.get(info.ssrComponentId); - this.unregisterComponent(existing!); - detachRootComponent(existing!.assignedRendererId as number, existing!.ssrComponentId); - this.registerComponent(descriptor); + public onComponentReset(): void { + for (const [key, value] of this._rootComponentsBySsrComponentId.entries()) { + detachRootComponent(value.assignedRendererId as number, key); + value.assignedRendererId = undefined; + value.uniqueIdAtLastUpdate = (value.uniqueIdAtLastUpdate ?? 0) + 1; } + this.rootComponentsMayRequireRefresh(); } } From 7b0e6ce3bff9a0663fef837661b802fcf66b98bf Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 28 May 2025 19:45:03 +0200 Subject: [PATCH 06/23] Refine ts --- .../src/Platform/Circuits/CircuitManager.ts | 5 ++- .../Web.JS/src/Rendering/BrowserRenderer.ts | 32 ++++++++----------- .../Web.JS/src/Rendering/Renderer.ts | 8 ----- .../src/Services/RootComponentManager.ts | 2 +- .../src/Services/WebRootComponentManager.ts | 13 ++++---- 5 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index b5c0baf8c288..a512834834f8 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -3,7 +3,7 @@ import { internalFunctions as navigationManagerFunctions } from '../../Services/NavigationManager'; import { toLogicalRootCommentElement, LogicalElement, toLogicalElement } from '../../Rendering/LogicalElements'; -import { ComponentDescriptor, ComponentMarker, ServerComponentDescriptor, descriptorToMarker } from '../../Services/ComponentDescriptorDiscovery'; +import { ServerComponentDescriptor, descriptorToMarker } from '../../Services/ComponentDescriptorDiscovery'; import { HttpTransportType, HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr'; import { getAndRemovePendingRootComponentContainer } from '../../Rendering/JSRootComponents'; import { RootComponentManager } from '../../Services/RootComponentManager'; @@ -241,8 +241,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { this._circuitId = resume; this._renderQueue = new RenderQueue(this._logger); - this._componentManager.onComponentReset?.(); - + this._componentManager.onComponentReload?.(); } this._options.reconnectionHandler!.onConnectionUp(); diff --git a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts index e012560692a8..9b1a42a3fa97 100644 --- a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts @@ -55,25 +55,6 @@ export class BrowserRenderer { elementsToClearOnRootComponentRender.add(element); } - public detachRootComponentFromLogicalElement(componentId: number): void { - const element = this.childComponentLocations[componentId]; - if (!element) { - throw new Error(`Root component '${componentId}' is not currently attached to any element`); - } - - // If the element is a root component, we remove the association with the component ID - // and mark it as no longer being an interactive root component. - markAsInteractiveRootComponentElement(element, false); - delete this.childComponentLocations[componentId]; - - for (const childNode of depthFirstNodeTreeTraversal(element)) { - if (childNode instanceof Element){ - this.eventDelegator.removeListenersForElement(childNode as Element); - } - emptyLogicalElement(childNode as LogicalElement); - } - } - public updateComponent(batch: RenderBatch, componentId: number, edits: ArrayBuilderSegment, referenceFrames: ArrayValues): void { const element = this.childComponentLocations[componentId]; if (!element) { @@ -82,6 +63,7 @@ export class BrowserRenderer { // On the first render for each root component, clear any existing content (e.g., prerendered) if (elementsToClearOnRootComponentRender.delete(element)) { + this.detachEventHandlersFromElement(element); emptyLogicalElement(element); if (element instanceof Comment) { @@ -128,6 +110,14 @@ export class BrowserRenderer { this.childComponentLocations[componentId] = element; } + private detachEventHandlersFromElement(element: LogicalElement): void { + for (const childNode of depthFirstNodeTreeTraversal(element)) { + if (childNode instanceof Element) { + this.eventDelegator.removeListenersForElement(childNode as Element); + } + } + } + private applyEdits(batch: RenderBatch, componentId: number, parent: LogicalElement, childIndex: number, edits: ArrayBuilderSegment, referenceFrames: ArrayValues) { let currentDepth = 0; let childIndexAtCurrentDepth = childIndex; @@ -407,6 +397,10 @@ export function setShouldPreserveContentOnInteractiveComponentDisposal(element: element[preserveContentOnDisposalPropname] = shouldPreserve; } +export function setClearContentOnRootComponentRerender(element: LogicalElement): void { + elementsToClearOnRootComponentRender.add(element); +} + function shouldPreserveContentOnInteractiveComponentDisposal(element: LogicalElement): boolean { return element[preserveContentOnDisposalPropname] === true; } diff --git a/src/Components/Web.JS/src/Rendering/Renderer.ts b/src/Components/Web.JS/src/Rendering/Renderer.ts index aed68cc98afc..a5619de92702 100644 --- a/src/Components/Web.JS/src/Rendering/Renderer.ts +++ b/src/Components/Web.JS/src/Rendering/Renderer.ts @@ -47,14 +47,6 @@ export function attachRootComponentToElement(elementSelector: string, componentI attachRootComponentToLogicalElement(browserRendererId, toLogicalElement(element, /* allow existing contents */ true), componentId, appendContent); } -export function detachRootComponent(browserRendererId: number, componentId: number): void { - const browserRenderer = browserRenderers[browserRendererId]; - if (!browserRenderer) { - throw new Error(`There is no browser renderer with ID ${browserRendererId}.`); - } - browserRenderer.detachRootComponentFromLogicalElement(componentId); -} - export function getRendererer(browserRendererId: number): BrowserRenderer | undefined { return browserRenderers[browserRendererId]; } diff --git a/src/Components/Web.JS/src/Services/RootComponentManager.ts b/src/Components/Web.JS/src/Services/RootComponentManager.ts index 7b9754a1dde6..7aef7399a59c 100644 --- a/src/Components/Web.JS/src/Services/RootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/RootComponentManager.ts @@ -7,6 +7,6 @@ export interface RootComponentManager { initialComponents: InitialComponentsDescriptorType[]; onAfterRenderBatch?(browserRendererId: number): void; onAfterUpdateRootComponents?(batchId: number): void; - onComponentReset?(): void; + onComponentReload?(): void; resolveRootComponent(ssrComponentId: number): ComponentDescriptor; } diff --git a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts index 9811e0104ac2..780099ff8ba4 100644 --- a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { ComponentDescriptor, ComponentMarker, descriptorToMarker, ServerComponentDescriptor, WebAssemblyServerOptions } from './ComponentDescriptorDiscovery'; +import { ComponentDescriptor, ComponentMarker, descriptorToMarker, WebAssemblyServerOptions } from './ComponentDescriptorDiscovery'; import { isRendererAttached, registerRendererAttachedListener } from '../Rendering/WebRendererInteropMethods'; import { WebRendererId } from '../Rendering/WebRendererId'; import { DescriptorHandler } from '../Rendering/DomMerging/DomSync'; @@ -9,9 +9,9 @@ import { disposeCircuit, hasStartedServer, isCircuitAvailable, startCircuit, sta import { hasLoadedWebAssemblyPlatform, hasStartedLoadingWebAssemblyPlatform, hasStartedWebAssembly, isFirstUpdate, loadWebAssemblyPlatformIfNotStarted, resolveInitialUpdate, setWaitForRootComponents, startWebAssembly, updateWebAssemblyRootComponents, waitForBootConfigLoaded } from '../Boot.WebAssembly.Common'; import { MonoConfig } from '@microsoft/dotnet-runtime'; import { RootComponentManager } from './RootComponentManager'; -import { detachRootComponent, getRendererer } from '../Rendering/Renderer'; +import { getRendererer } from '../Rendering/Renderer'; import { isPageLoading } from './NavigationEnhancement'; -import { setShouldPreserveContentOnInteractiveComponentDisposal } from '../Rendering/BrowserRenderer'; +import { setClearContentOnRootComponentRerender, setShouldPreserveContentOnInteractiveComponentDisposal } from '../Rendering/BrowserRenderer'; import { LogicalElement } from '../Rendering/LogicalElements'; type RootComponentOperationBatch = { @@ -464,11 +464,10 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent } } - public onComponentReset(): void { - for (const [key, value] of this._rootComponentsBySsrComponentId.entries()) { - detachRootComponent(value.assignedRendererId as number, key); + public onComponentReload(): void { + for (const [_, value] of this._rootComponentsBySsrComponentId.entries()) { value.assignedRendererId = undefined; - value.uniqueIdAtLastUpdate = (value.uniqueIdAtLastUpdate ?? 0) + 1; + setClearContentOnRootComponentRerender(value.descriptor.start as unknown as LogicalElement); } this.rootComponentsMayRequireRefresh(); From 58fbda46a3fec2705183b9f67cdcf952a7e8d603 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 28 May 2025 20:44:27 +0200 Subject: [PATCH 07/23] Wire up the default handler --- .../Web.JS/src/Boot.Server.Common.ts | 14 ++++++ src/Components/Web.JS/src/GlobalExports.ts | 1 + .../src/Platform/Circuits/CircuitManager.ts | 43 +++++++++++-------- .../Platform/Circuits/CircuitStartOptions.ts | 1 + .../Circuits/DefaultReconnectionHandler.ts | 27 ++++++++++-- 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/Components/Web.JS/src/Boot.Server.Common.ts b/src/Components/Web.JS/src/Boot.Server.Common.ts index f8b60ccb055d..a882808146a7 100644 --- a/src/Components/Web.JS/src/Boot.Server.Common.ts +++ b/src/Components/Web.JS/src/Boot.Server.Common.ts @@ -67,6 +67,20 @@ async function startServerCore(components: RootComponentManager { + if (circuit.didRenderingFail()) { + // We can't resume after a failure, so exit early. + return false; + } + + if (!(await circuit.resume())) { + logger.log(LogLevel.Information, 'Resume attempt to the circuit was rejected by the server. This may indicate that the associated state is no longer available on the server.'); + return false; + } + + return true; + }; + Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger); options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler; diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index 261ed2c2ce5d..3df06a78bf3a 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -38,6 +38,7 @@ export interface IBlazor { removeEventListener?: typeof JSEventRegistry.prototype.removeEventListener; disconnect?: () => void; reconnect?: (existingConnection?: HubConnection) => Promise; + resume?: (existingConnection?: HubConnection) => Promise; defaultReconnectionHandler?: DefaultReconnectionHandler; start?: ((userOptions?: Partial) => Promise) | ((options?: Partial) => Promise) | ((options?: Partial) => Promise); platform?: Platform; diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index a512834834f8..603b056f82d5 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -20,6 +20,7 @@ import { attachWebRendererInterop, detachWebRendererInterop } from '../../Render import { sendJSDataStream } from './CircuitStreamingInterop'; export class CircuitManager implements DotNet.DotNetCallDispatcher { + private readonly _componentManager: RootComponentManager; private _applicationState: string; @@ -224,28 +225,36 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { } if (!await this._connection!.invoke('ConnectCircuit', this._circuitId)) { - const resume = await this._connection!.invoke( - 'ResumeCircuit', - this._circuitId, - navigationManagerFunctions.getBaseURI(), - navigationManagerFunctions.getLocationHref(), - '[]', - '' - ); - if (!resume) { - detachWebRendererInterop(WebRendererId.Server); - this._interopMethodsForReconnection = undefined; - return false; - } + return false; + } - this._circuitId = resume; - this._renderQueue = new RenderQueue(this._logger); + this._options.reconnectionHandler!.onConnectionUp(); - this._componentManager.onComponentReload?.(); + return true; + } + + public async resume(): Promise { + if (!this._circuitId) { + throw new Error('Method not implemented.'); } - this._options.reconnectionHandler!.onConnectionUp(); + const resume = await this._connection!.invoke( + 'ResumeCircuit', + this._circuitId, + navigationManagerFunctions.getBaseURI(), + navigationManagerFunctions.getLocationHref(), + '[]', + '' + ); + if (!resume) { + return false; + } + this._circuitId = resume; + this._renderQueue = new RenderQueue(this._logger); + this._options.reconnectionHandler!.onCircuitResumed(); + this._options.reconnectionHandler!.onConnectionUp(); + this._componentManager.onComponentReload?.(); return true; } diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts index ac94b7a2d781..080b5a2063a3 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts @@ -47,6 +47,7 @@ export interface CircuitHandler { export interface ReconnectionHandler { onConnectionDown(options: ReconnectionOptions, error?: Error): void; onConnectionUp(): void; + onCircuitResumed(): void; } function computeDefaultRetryInterval(previousAttempts: number, maxRetries?: number): number | null { diff --git a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts index 58750dce46e6..18ff3e74d249 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts @@ -13,14 +13,17 @@ export class DefaultReconnectionHandler implements ReconnectionHandler { private readonly _reconnectCallback: () => Promise; + private readonly _resumeCallback: () => Promise; + private _currentReconnectionProcess: ReconnectionProcess | null = null; private _reconnectionDisplay?: ReconnectDisplay; - constructor(logger: Logger, overrideDisplay?: ReconnectDisplay, reconnectCallback?: () => Promise) { + constructor(logger: Logger, overrideDisplay?: ReconnectDisplay, reconnectCallback?: () => Promise, resumeCallback?: () => Promise) { this._logger = logger; this._reconnectionDisplay = overrideDisplay; this._reconnectCallback = reconnectCallback || Blazor.reconnect!; + this._resumeCallback = resumeCallback || Blazor.resume!; } onConnectionDown(options: ReconnectionOptions, _error?: Error): void { @@ -32,7 +35,13 @@ export class DefaultReconnectionHandler implements ReconnectionHandler { } if (!this._currentReconnectionProcess) { - this._currentReconnectionProcess = new ReconnectionProcess(options, this._logger, this._reconnectCallback, this._reconnectionDisplay); + this._currentReconnectionProcess = new ReconnectionProcess( + options, + this._logger, + this._reconnectCallback, + this._resumeCallback, + this._reconnectionDisplay + ); } } @@ -42,6 +51,10 @@ export class DefaultReconnectionHandler implements ReconnectionHandler { this._currentReconnectionProcess = null; } } + + onCircuitResumed(): void { + return; + } } class ReconnectionProcess { @@ -51,7 +64,7 @@ class ReconnectionProcess { isDisposed = false; - constructor(options: ReconnectionOptions, private logger: Logger, private reconnectCallback: () => Promise, display: ReconnectDisplay) { + constructor(options: ReconnectionOptions, private logger: Logger, private reconnectCallback: () => Promise, private resumeCallback: () => Promise, display: ReconnectDisplay) { this.reconnectDisplay = display; this.reconnectDisplay.show(); this.attemptPeriodicReconnection(options); @@ -65,7 +78,7 @@ class ReconnectionProcess { async attemptPeriodicReconnection(options: ReconnectionOptions) { for (let i = 0; options.maxRetries === undefined || i < options.maxRetries; i++) { let retryInterval: number; - if (typeof(options.retryIntervalMilliseconds) === 'function') { + if (typeof (options.retryIntervalMilliseconds) === 'function') { const computedRetryInterval = options.retryIntervalMilliseconds(i); if (computedRetryInterval === null || computedRetryInterval === undefined) { break; @@ -92,6 +105,12 @@ class ReconnectionProcess { // - exception to mean we didn't reach the server (this can be sync or async) const result = await this.reconnectCallback(); if (!result) { + // Try to resume the circuit if the reconnect failed + const resumeResult = await this.resumeCallback(); + if (resumeResult) { + return; + } + // If the server responded and refused to reconnect, stop auto-retrying. this.reconnectDisplay.rejected(); return; From fe8b299d84b057f55640da58f133f0a024aea7f1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 29 May 2025 11:18:51 +0200 Subject: [PATCH 08/23] Sample cleanups --- .../Samples/BlazorUnitedApp/App.razor | 23 ++++++++++--------- .../BlazorUnitedApp/Data/WeatherForecast.cs | 6 +++++ .../BlazorUnitedApp/Pages/FetchData.razor | 5 +++- .../Samples/BlazorUnitedApp/Program.cs | 4 ++-- .../Properties/launchSettings.json | 2 +- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Components/Samples/BlazorUnitedApp/App.razor b/src/Components/Samples/BlazorUnitedApp/App.razor index 6a2e7fcfbbc0..b5c41cf8bd3c 100644 --- a/src/Components/Samples/BlazorUnitedApp/App.razor +++ b/src/Components/Samples/BlazorUnitedApp/App.razor @@ -17,17 +17,18 @@ - + + diff --git a/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecast.cs b/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecast.cs index bcc88096eed7..bae11804c4db 100644 --- a/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecast.cs +++ b/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecast.cs @@ -1,11 +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.Diagnostics; - namespace BlazorUnitedApp.Data; -[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public class WeatherForecast { public DateOnly Date { get; set; } @@ -15,7 +12,4 @@ public class WeatherForecast public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string? Summary { get; set; } - - private string GetDebuggerDisplay() => - $"{Date:yyyy-MM-dd}: {TemperatureC}°C ({TemperatureF}°F) - {Summary ?? "No summary"}"; } diff --git a/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs b/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs index 65ab41606876..7a997104f02c 100644 --- a/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs +++ b/src/Components/Samples/BlazorUnitedApp/Data/WeatherForecastService.cs @@ -10,14 +10,13 @@ public class WeatherForecastService "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - public async Task GetForecastAsync(DateOnly startDate) + public Task GetForecastAsync(DateOnly startDate) { - await Task.Delay(1000); - return [.. Enumerable.Range(1, 5).Select(index => new WeatherForecast + return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = startDate.AddDays(index), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] - })]; + }).ToArray()); } } diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor b/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor index 100694cd6e0f..46af23cf37fb 100644 --- a/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor +++ b/src/Components/Samples/BlazorUnitedApp/Pages/FetchData.razor @@ -6,11 +6,9 @@

Weather forecast

-@_guid -

This component demonstrates fetching data from a service.

-@if (Forecasts == null) +@if (forecasts == null) {

Loading...

} @@ -26,7 +24,7 @@ else - @foreach (var forecast in Forecasts) + @foreach (var forecast in forecasts) { @forecast.Date.ToShortDateString() @@ -40,14 +38,10 @@ else } @code { - - [SupplyParameterFromPersistentComponentState] public WeatherForecast[]? Forecasts { get; set; } - - private string _guid; + private WeatherForecast[]? forecasts; protected override async Task OnInitializedAsync() { - _guid = Guid.NewGuid().ToString(); - Forecasts ??= await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now)); + forecasts = await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now)); } } diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 2a759ecc6190..6492f3fb3e50 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -9,11 +9,7 @@ // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents() - .AddInteractiveServerComponents(o => - { - o.DisconnectedCircuitMaxRetained = 0; - o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromSeconds(0); - }); + .AddInteractiveServerComponents(); builder.Services.AddSingleton(); diff --git a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json index 4d89bf003210..265de0cd64ad 100644 --- a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json +++ b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json @@ -22,8 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "fetchdata", - "applicationUrl": "http://localhost:5265", + "applicationUrl": "https://localhost:7247;http://localhost:5265", //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor b/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor index 85df08c17138..3801dfc18932 100644 --- a/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor +++ b/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor @@ -29,16 +29,6 @@ Web assembly - - From fabd5f7d25b1682497986404560fdadbced5cde4 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 5 Jun 2025 17:55:09 +0200 Subject: [PATCH 18/23] Fix build --- src/Components/Server/test/Circuits/TestCircuitIdFactory.cs | 2 +- .../test/testassets/TestContentPackage/PersistentCounter.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs b/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs index a7a9a4c7caf9..32b286b522a0 100644 --- a/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs +++ b/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; internal class TestCircuitIdFactory { - public static CircuitIdFactory? Instance { get; } = CreateTestFactory(); + public static CircuitIdFactory Instance { get; } = CreateTestFactory(); public static CircuitIdFactory CreateTestFactory() { diff --git a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor index 77ffe7abe05a..fef4f1ecaa3f 100644 --- a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor +++ b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor @@ -21,7 +21,7 @@ public int Count { get; set; } = 0; } - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { // State is preserved across disconnections State ??= new CounterState(); From 49d73df4cf57586438c24ece994c4a16268ff652 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 5 Jun 2025 18:21:19 +0200 Subject: [PATCH 19/23] Tmp --- .../Shared/src/WebRootComponentManager.cs | 1 - .../Web.JS/src/Platform/Circuits/CircuitManager.ts | 1 - .../src/Platform/Circuits/CircuitStartOptions.ts | 1 - .../Circuits/DefaultReconnectionHandler.ts | 4 ---- .../ServerFixtures/AspNetSiteServerFixture.cs | 14 +++++--------- 5 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Components/Shared/src/WebRootComponentManager.cs b/src/Components/Shared/src/WebRootComponentManager.cs index 5b62fb51fed5..99057d534148 100644 --- a/src/Components/Shared/src/WebRootComponentManager.cs +++ b/src/Components/Shared/src/WebRootComponentManager.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Numerics; using static Microsoft.AspNetCore.Internal.LinkerFlags; #if COMPONENTS_SERVER diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index dda55ff26207..c6a1c5d42ead 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -267,7 +267,6 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { } } - this._options.reconnectionHandler!.onCircuitResumed(); this._options.reconnectionHandler!.onConnectionUp(); this._componentManager.onComponentReload?.(); return true; diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts index 080b5a2063a3..ac94b7a2d781 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts @@ -47,7 +47,6 @@ export interface CircuitHandler { export interface ReconnectionHandler { onConnectionDown(options: ReconnectionOptions, error?: Error): void; onConnectionUp(): void; - onCircuitResumed(): void; } function computeDefaultRetryInterval(previousAttempts: number, maxRetries?: number): number | null { diff --git a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts index 18ff3e74d249..255677ad92a8 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts @@ -51,10 +51,6 @@ export class DefaultReconnectionHandler implements ReconnectionHandler { this._currentReconnectionProcess = null; } } - - onCircuitResumed(): void { - return; - } } class ReconnectionProcess { diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs index cc5d236c037d..bd51576b15fe 100644 --- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs +++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs @@ -26,8 +26,6 @@ public class AspNetSiteServerFixture : WebHostServerFixture public List AdditionalArguments { get; set; } = new List { "--test-execution-mode", "server" }; - public Action ConfigureAdditionalServices { get; set; } = _ => { }; - protected override IHost CreateWebHost() { if (BuildWebHostMethod == null) @@ -45,14 +43,12 @@ protected override IHost CreateWebHost() host = E2ETestOptions.Instance.Sauce.HostName; } - var arguments = new[] + var result = BuildWebHostMethod(new[] { - "--urls", $"http://{host}:0", - "--contentroot", sampleSitePath, - "--environment", Environment.ToString(), - }; - - var result = BuildWebHostMethod([.. arguments, .. AdditionalArguments]); + "--urls", $"http://{host}:0", + "--contentroot", sampleSitePath, + "--environment", Environment.ToString(), + }.Concat(AdditionalArguments).ToArray()); UpdateHostServices?.Invoke(result.Services); From 6859448d925298c3157aa684618decfa72028ece Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 9 Jun 2025 20:25:05 +0200 Subject: [PATCH 20/23] Address feedback --- .../Server/src/Circuits/CircuitHost.cs | 5 +- .../src/Circuits/CircuitPersistenceManager.cs | 87 ++++++++----------- ...aultInMemoryCircuitPersistenceProvider.cs} | 9 +- .../Circuits/IServerComponentDeserializer.cs | 2 +- .../src/Circuits/PersistedCircuitState.cs | 2 +- src/Components/Server/src/ComponentHub.cs | 2 +- .../Server/test/Circuits/ComponentHubTest.cs | 5 +- ...InMemoryCircuitPersistenceProviderTest.cs} | 2 +- .../src/Hosting/WebAssemblyHost.cs | 3 +- .../PrerenderComponentApplicationStore.cs | 4 +- ...ectedPrerenderComponentApplicationStore.cs | 4 +- 11 files changed, 59 insertions(+), 66 deletions(-) rename src/Components/Server/src/Circuits/{DefaultPersistedCircuitCache.cs => DefaultInMemoryCircuitPersistenceProvider.cs} (94%) rename src/Components/Server/test/Circuits/{DefaultPersistedCircuitCacheTest.cs => DefaultInMemoryCircuitPersistenceProviderTest.cs} (99%) diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 3f47d11a8f3a..4f5c9d57457b 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -161,7 +162,7 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, A // web service, preventing UI updates. if (Descriptors.Count > 0) { - store.ExistingState.Clear(); + store.ExistingState = ReadOnlyDictionary.Empty; } // This variable is used to track that this is the first time we are updating components. @@ -821,7 +822,7 @@ internal Task UpdateRootComponents( // At this point all components have successfully produced an initial render and we can clear the contents of the component // application state store. This ensures the memory that was not used during the initial render of these components gets // reclaimed since no-one else is holding on to it any longer. - store.ExistingState.Clear(); + store.ExistingState = ReadOnlyDictionary.Empty; } } }); diff --git a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs index c1c0ee28c090..df1d0c7f73ca 100644 --- a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs +++ b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs @@ -16,45 +16,23 @@ internal partial class CircuitPersistenceManager( ServerComponentSerializer serverComponentSerializer, ICircuitPersistenceProvider circuitPersistenceProvider) { - private const string CircuitPersistenceManagerKey = $"Microsoft.AspNetCore.Components.Server.Circuits.{nameof(CircuitPersistenceManager)}"; - public async Task PauseCircuitAsync(CircuitHost circuit, CancellationToken cancellation = default) { var renderer = circuit.Renderer; var persistenceManager = circuit.Services.GetRequiredService(); + var collector = new CircuitPersistenceManagerCollector(circuitOptions, serverComponentSerializer, circuit.Renderer); using var subscription = persistenceManager.State.RegisterOnPersisting( - () => PersistRootComponents(renderer, persistenceManager.State), + collector.PersistRootComponents, RenderMode.InteractiveServer); - var store = new CircuitPersistenceManagerStore(); - await persistenceManager.PersistStateAsync(store, renderer); + + await persistenceManager.PersistStateAsync(collector, renderer); await circuitPersistenceProvider.PersistCircuitAsync( circuit.CircuitId, - store.PersistedCircuitState, + collector.PersistedCircuitState, cancellation); } - private Task PersistRootComponents(RemoteRenderer renderer, PersistentComponentState state) - { - var persistedComponents = new Dictionary(); - var components = renderer.GetOrCreateWebRootComponentManager().GetRootComponents(); - var invocation = new ServerComponentInvocationSequence(); - foreach (var (id, componentKey, (componentType, parameters)) in components) - { - var distributedRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod; - var localRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod; - var maxRetention = distributedRetention > localRetention ? distributedRetention : localRetention; - - var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, false, componentKey); - serverComponentSerializer.SerializeInvocation(ref marker, invocation, componentType, parameters, maxRetention); - persistedComponents.Add(id, marker); - } - - state.PersistAsJson(CircuitPersistenceManagerKey, persistedComponents); - - return Task.CompletedTask; - } - public async Task ResumeCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default) { return await circuitPersistenceProvider.RestoreCircuitAsync(circuitId, cancellation); @@ -63,7 +41,7 @@ public async Task ResumeCircuitAsync(CircuitId circuitId, // We are going to construct a RootComponentOperationBatch but we are going to replace the descriptors from the client with the // descriptors that we have persisted when pausing the circuit. // The way pausing and resuming works is that when the client starts the resume process, it 'simulates' that an SSR has happened and - // queues and 'Add' operation for each server-side component that is on the document. + // queues an 'Add' operation for each server-side component that is on the document. // That ends up calling UpdateRootComponents with the old descriptors and no application state. // On the server side, we replace the descriptors with the ones that we have persisted. We can't use the original descriptors because // those have a lifetime of ~ 5 minutes, after which we are not able to unprotect them anymore. @@ -139,10 +117,40 @@ static Dictionary TryDeserializeMarkers(byte[] rootCompone } } - private class CircuitPersistenceManagerStore : IPersistentComponentStateStore + private class CircuitPersistenceManagerCollector( + IOptions circuitOptions, + ServerComponentSerializer serverComponentSerializer, + RemoteRenderer renderer) + : IPersistentComponentStateStore { internal PersistedCircuitState PersistedCircuitState { get; private set; } + public Task PersistRootComponents() + { + var persistedComponents = new Dictionary(); + var components = renderer.GetOrCreateWebRootComponentManager().GetRootComponents(); + var invocation = new ServerComponentInvocationSequence(); + foreach (var (id, componentKey, (componentType, parameters)) in components) + { + var distributedRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod; + var localRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod; + var maxRetention = distributedRetention > localRetention ? distributedRetention : localRetention; + + var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, prerendered: false, componentKey); + serverComponentSerializer.SerializeInvocation(ref marker, invocation, componentType, parameters, maxRetention); + persistedComponents.Add(id, marker); + } + + PersistedCircuitState = new PersistedCircuitState + { + RootComponents = JsonSerializer.SerializeToUtf8Bytes( + persistedComponents, + CircuitPersistenceManagerSerializerContext.Default.DictionaryInt32ComponentMarker) + }; + + return Task.CompletedTask; + } + // This store only support serializing the state Task> IPersistentComponentStateStore.GetPersistedStateAsync() => throw new NotImplementedException(); @@ -152,26 +160,7 @@ private class CircuitPersistenceManagerStore : IPersistentComponentStateStore // and store them separately from the other state. Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary state) { - var dictionary = new Dictionary(state.Count - 1); - byte[] rootComponentMarkers = null; - foreach (var (key, value) in state) - { - if (key == CircuitPersistenceManagerKey) - { - rootComponentMarkers = value; - } - else - { - dictionary[key] = value; - } - } - - PersistedCircuitState = new PersistedCircuitState - { - ApplicationState = dictionary, - RootComponents = rootComponentMarkers - }; - + PersistedCircuitState.ApplicationState = state; return Task.CompletedTask; } } diff --git a/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs b/src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs similarity index 94% rename from src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs rename to src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs index 5c12aaffd54c..80679549690b 100644 --- a/src/Components/Server/src/Circuits/DefaultPersistedCircuitCache.cs +++ b/src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs @@ -16,7 +16,7 @@ internal sealed partial class DefaultInMemoryCircuitPersistenceProvider : ICircu private readonly Lock _lock = new(); private readonly CircuitOptions _options; private readonly MemoryCache _persistedCircuits; - private readonly Task _noMatch = Task.FromResult(null); + private static readonly Task _noMatch = Task.FromResult(null); private readonly ILogger _logger; public PostEvictionCallbackRegistration PostEvictionCallback { get; internal set; } @@ -66,7 +66,8 @@ private void PersistCore(CircuitId circuitId, PersistedCircuitState persistedCir var persistedCircuitEntry = new PersistedCircuitEntry { State = persistedCircuitState, - TokenSource = cancellationTokenSource + TokenSource = cancellationTokenSource, + CircuitId = circuitId }; _persistedCircuits.Set(circuitId.Secret, persistedCircuitEntry, options); @@ -81,13 +82,13 @@ private void OnEntryEvicted(object key, object value, EvictionReason reason, obj // Happens after the circuit state times out, this is triggered by the CancellationTokenSource we register // with the entry, which is what controls the expiration case EvictionReason.Capacity: - // Happens when the cache is full + // Happens when the cache is full var persistedCircuitEntry = (PersistedCircuitEntry)value; Log.CircuitStateEvicted(_logger, persistedCircuitEntry.CircuitId, reason); break; case EvictionReason.Removed: - // Happens when the entry is explicitly removed as part of resuming a circuit. + // Happens when the entry is explicitly removed as part of resuming a circuit. return; default: Debug.Fail($"Unexpected {nameof(EvictionReason)} {reason}"); diff --git a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs index 003bd0de81d9..b118a0cd6034 100644 --- a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs @@ -12,5 +12,5 @@ bool TryDeserializeComponentDescriptorCollection( out List descriptors); bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? operationBatch, bool deserializeDescriptors = true); - public bool TryDeserializeWebRootComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out WebRootComponentDescriptor? result); + bool TryDeserializeWebRootComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out WebRootComponentDescriptor? result); } diff --git a/src/Components/Server/src/Circuits/PersistedCircuitState.cs b/src/Components/Server/src/Circuits/PersistedCircuitState.cs index bd261ccf345d..81f1277d66ad 100644 --- a/src/Components/Server/src/Circuits/PersistedCircuitState.cs +++ b/src/Components/Server/src/Circuits/PersistedCircuitState.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] internal class PersistedCircuitState { - public Dictionary ApplicationState { get; internal set; } + public IReadOnlyDictionary ApplicationState { get; internal set; } public byte[] RootComponents { get; internal set; } diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index dc62c28462b4..d91942c7711a 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -312,7 +312,7 @@ public async ValueTask ResumeCircuit( } PersistedCircuitState? persistedCircuitState; - if (rootComponents == "[]" && string.IsNullOrEmpty(applicationState)) + if (RootComponentIsEmpty(rootComponents) && string.IsNullOrEmpty(applicationState)) { persistedCircuitState = await _circuitPersistenceManager.ResumeCircuitAsync(circuitId, Context.ConnectionAborted); if (persistedCircuitState == null) diff --git a/src/Components/Server/test/Circuits/ComponentHubTest.cs b/src/Components/Server/test/Circuits/ComponentHubTest.cs index 8998a23ac77b..35d9af2ce8a4 100644 --- a/src/Components/Server/test/Circuits/ComponentHubTest.cs +++ b/src/Components/Server/test/Circuits/ComponentHubTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text.RegularExpressions; @@ -164,7 +165,7 @@ public async Task CanCallUpdateRootComponentsOnResumedCircuit() .ReturnsAsync(new PersistedCircuitState { RootComponents = [.. """{}"""u8], - ApplicationState = [] + ApplicationState = ReadOnlyDictionary.Empty }); var (mockClientProxy, hub) = InitializeComponentHub(deserializer, handleRegistryMock.Object, providerMock.Object); @@ -264,7 +265,7 @@ public async Task CanResumeAppWhenPersistedComponentStateIsAvailable() .ReturnsAsync(new PersistedCircuitState { RootComponents = [], - ApplicationState = [] + ApplicationState = ReadOnlyDictionary.Empty, }); var (mockClientProxy, hub) = InitializeComponentHub(null, handleRegistryMock.Object, providerMock.Object); diff --git a/src/Components/Server/test/Circuits/DefaultPersistedCircuitCacheTest.cs b/src/Components/Server/test/Circuits/DefaultInMemoryCircuitPersistenceProviderTest.cs similarity index 99% rename from src/Components/Server/test/Circuits/DefaultPersistedCircuitCacheTest.cs rename to src/Components/Server/test/Circuits/DefaultInMemoryCircuitPersistenceProviderTest.cs index 1ec13d52f424..26abca17d1fe 100644 --- a/src/Components/Server/test/Circuits/DefaultPersistedCircuitCacheTest.cs +++ b/src/Components/Server/test/Circuits/DefaultInMemoryCircuitPersistenceProviderTest.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Server.Tests.Circuits; -public class DefaultPersistedCircuitCacheTest +public class DefaultInMemoryCircuitPersistenceProviderTest { [Fact] public async Task PersistCircuitAsync_StoresCircuitState() diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index 21607a9f29d9..eabc666715f8 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Reflection.Metadata; using Microsoft.AspNetCore.Components.Infrastructure; @@ -204,7 +205,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl }); await initializationTcs.Task; - store.ExistingState.Clear(); + store.ExistingState = ReadOnlyDictionary.Empty; await tcs.Task; } diff --git a/src/Shared/Components/PrerenderComponentApplicationStore.cs b/src/Shared/Components/PrerenderComponentApplicationStore.cs index 8c66acead0cc..06f643ec36e0 100644 --- a/src/Shared/Components/PrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/PrerenderComponentApplicationStore.cs @@ -16,7 +16,7 @@ internal class PrerenderComponentApplicationStore : IPersistentComponentStateSto public PrerenderComponentApplicationStore() { - ExistingState = new(); + ExistingState = new Dictionary(); } [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Simple deserialize of primitive types.")] @@ -41,7 +41,7 @@ protected void DeserializeState(byte[] existingState) public string? PersistedState { get; private set; } #nullable disable - public Dictionary ExistingState { get; protected set; } + public IReadOnlyDictionary ExistingState { get; protected internal set; } public Task> GetPersistedStateAsync() { diff --git a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs index adc599661dd9..d564233552c9 100644 --- a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs @@ -21,10 +21,10 @@ public ProtectedPrerenderComponentApplicationStore(string existingState, IDataPr DeserializeState(_protector.Unprotect(Convert.FromBase64String(existingState))); } - public ProtectedPrerenderComponentApplicationStore(Dictionary deserializedState, IDataProtectionProvider dataProtectionProvider) + public ProtectedPrerenderComponentApplicationStore(IReadOnlyDictionary deserializedState, IDataProtectionProvider dataProtectionProvider) { CreateProtector(dataProtectionProvider); - ExistingState = deserializedState; + ExistingState = new Dictionary(deserializedState); } protected override byte[] SerializeState(IReadOnlyDictionary state) From d9872a15f1bc3a5e7c6b25a4ecd307e0a3887613 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 10 Jun 2025 11:25:38 +0200 Subject: [PATCH 21/23] Fix test --- .../Server/src/Circuits/CircuitHost.cs | 5 +- .../src/Hosting/WebAssemblyHost.cs | 3 +- .../src/package.json.bak | 47 ++++++++++++++++ .../PrerenderComponentApplicationStore.cs | 2 +- .../signalr-protocol-msgpack/package.json.bak | 51 ++++++++++++++++++ .../clients/ts/signalr/package.json.bak | 54 +++++++++++++++++++ 6 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak create mode 100644 src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak create mode 100644 src/SignalR/clients/ts/signalr/package.json.bak diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 4f5c9d57457b..3f47d11a8f3a 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -162,7 +161,7 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, A // web service, preventing UI updates. if (Descriptors.Count > 0) { - store.ExistingState = ReadOnlyDictionary.Empty; + store.ExistingState.Clear(); } // This variable is used to track that this is the first time we are updating components. @@ -822,7 +821,7 @@ internal Task UpdateRootComponents( // At this point all components have successfully produced an initial render and we can clear the contents of the component // application state store. This ensures the memory that was not used during the initial render of these components gets // reclaimed since no-one else is holding on to it any longer. - store.ExistingState = ReadOnlyDictionary.Empty; + store.ExistingState.Clear(); } } }); diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index eabc666715f8..21607a9f29d9 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Reflection.Metadata; using Microsoft.AspNetCore.Components.Infrastructure; @@ -205,7 +204,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl }); await initializationTcs.Task; - store.ExistingState = ReadOnlyDictionary.Empty; + store.ExistingState.Clear(); await tcs.Task; } diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak new file mode 100644 index 000000000000..4a91339cf24f --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json.bak @@ -0,0 +1,47 @@ +{ + "name": "@microsoft/dotnet-js-interop", + "version": "10.0.0-dev", + "description": "Provides abstractions and features for interop between .NET and JavaScript code.", + "main": "dist/src/Microsoft.JSInterop.js", + "types": "dist/src/Microsoft.JSInterop.d.ts", + "type": "module", + "scripts": { + "clean": "rimraf ./dist", + "test": "jest", + "test:watch": "jest --watch", + "test:debug": "node --nolazy --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --colors --verbose", + "build": "npm run clean && npm run build:esm", + "build:lint": "eslint -c .eslintrc.json --ext .ts ./src", + "build:esm": "tsc --project ./tsconfig.json", + "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/extensions.git" + }, + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotnet/aspnetcore/issues" + }, + "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/JSInterop", + "files": [ + "dist/**" + ], + "devDependencies": { + "@babel/core": "^7.23.6", + "@babel/preset-env": "^7.23.6", + "@babel/preset-typescript": "^7.26.0", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "babel-jest": "^29.7.0", + "eslint": "^8.56.0", + "eslint-plugin-jsdoc": "^46.9.1", + "eslint-plugin-prefer-arrow": "^1.2.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-junit": "^16.0.0", + "rimraf": "^5.0.5", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/src/Shared/Components/PrerenderComponentApplicationStore.cs b/src/Shared/Components/PrerenderComponentApplicationStore.cs index 06f643ec36e0..761f9c4521fc 100644 --- a/src/Shared/Components/PrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/PrerenderComponentApplicationStore.cs @@ -41,7 +41,7 @@ protected void DeserializeState(byte[] existingState) public string? PersistedState { get; private set; } #nullable disable - public IReadOnlyDictionary ExistingState { get; protected internal set; } + public Dictionary ExistingState { get; protected set; } public Task> GetPersistedStateAsync() { diff --git a/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak new file mode 100644 index 000000000000..c9cc83805bbc --- /dev/null +++ b/src/SignalR/clients/ts/signalr-protocol-msgpack/package.json.bak @@ -0,0 +1,51 @@ +{ + "name": "@microsoft/signalr-protocol-msgpack", + "version": "5.0.0-dev", + "description": "MsgPack Protocol support for ASP.NET Core SignalR", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "typings": "./dist/esm/index.d.ts", + "umd": "./dist/browser/signalr-protocol-msgpack.js", + "umd_name": "signalR.protocols.msgpack", + "unpkg": "./dist/browser/signalr-protocol-msgpack.js", + "directories": { + "test": "spec" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf ./dist", + "prebuild": "rimraf ./src/pkg-version.ts && node -e \"const fs = require('fs'); const packageJson = require('./package.json'); fs.writeFileSync('./src/pkg-version.ts', 'export const VERSION = \\'' + packageJson.version + '\\';');\"", + "build": "npm run build:esm && npm run build:cjs && npm run build:browser && npm run build:uglify", + "build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir ./dist/esm -d", + "build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir ./dist/cjs", + "build:browser": "webpack-cli", + "build:uglify": "terser -m -c --ecma 2019 --module --source-map \"url='signalr-protocol-msgpack.min.js.map',content='./dist/browser/signalr-protocol-msgpack.js.map'\" --comments -o ./dist/browser/signalr-protocol-msgpack.min.js ./dist/browser/signalr-protocol-msgpack.js", + "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" + }, + "keywords": [ + "signalr", + "aspnetcore" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/aspnetcore.git" + }, + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotnet/aspnetcore/issues" + }, + "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/SignalR#readme", + "files": [ + "dist/**/*", + "src/**/*" + ], + "dependencies": { + "@microsoft/signalr": "*", + "@msgpack/msgpack": "^2.7.0" + }, + "overrides": { + "ws": ">=7.4.6", + "tough-cookie": ">=4.1.3" + } +} diff --git a/src/SignalR/clients/ts/signalr/package.json.bak b/src/SignalR/clients/ts/signalr/package.json.bak new file mode 100644 index 000000000000..805aed5a9bb2 --- /dev/null +++ b/src/SignalR/clients/ts/signalr/package.json.bak @@ -0,0 +1,54 @@ +{ + "name": "@microsoft/signalr", + "version": "5.0.0-dev", + "description": "ASP.NET Core SignalR Client", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "typings": "./dist/esm/index.d.ts", + "umd": "./dist/browser/signalr.js", + "umd_name": "signalR", + "unpkg": "./dist/browser/signalr.js", + "directories": { + "test": "spec" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf ./dist", + "prebuild": "rimraf ./src/pkg-version.ts && node -e \"const fs = require('fs'); const packageJson = require('./package.json'); fs.writeFileSync('./src/pkg-version.ts', 'export const VERSION = \\'' + packageJson.version + '\\';');\"", + "build": "npm run build:esm && npm run build:cjs && npm run build:browser && npm run build:webworker", + "build:esm": "tsc --project ./tsconfig.json --module es2015 --outDir ./dist/esm -d", + "build:cjs": "tsc --project ./tsconfig.json --module commonjs --outDir ./dist/cjs", + "build:browser": "webpack-cli", + "build:webworker": "webpack-cli --env platform=webworker", + "get-version": "node -e \"const { name, version } = require('./package.json'); console.log(`${name};${version}`);\"" + }, + "keywords": [ + "signalr", + "aspnetcore" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/aspnetcore.git" + }, + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotnet/aspnetcore/issues" + }, + "homepage": "https://github.com/dotnet/aspnetcore/tree/main/src/SignalR#readme", + "files": [ + "dist/**/*", + "src/**/*" + ], + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + }, + "overrides": { + "ansi-regex": "5.0.1", + "tough-cookie": ">=4.1.3" + } +} From 0341ceec8ea6abac1243e33f4e08fb11bd3d8669 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 10 Jun 2025 11:55:36 +0200 Subject: [PATCH 22/23] Improve tests --- .../ServerExecutionTests/ServerResumeTests.cs | 22 +++++++++++++++---- .../RazorComponents/Root.razor | 13 +++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs index d6da0f564e23..11f6107d2ce4 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs @@ -37,9 +37,26 @@ public void CanResumeCircuitAfterDisconnection() Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); - var previousText = Browser.Exists(By.Id("persistent-counter-render")).Text; var javascript = (IJavaScriptExecutor)Browser; javascript.ExecuteScript("window.replaceReconnectCallback()"); + + TriggerReconnectAndInteract(javascript); + + // Can dispatch events after reconnect + Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + + javascript.ExecuteScript("resetReconnect()"); + + TriggerReconnectAndInteract(javascript); + + // Ensure that reconnection events are repeatable + Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + } + + private void TriggerReconnectAndInteract(IJavaScriptExecutor javascript) + { + var previousText = Browser.Exists(By.Id("persistent-counter-render")).Text; + javascript.ExecuteScript("Blazor._internal.forceCloseConnection()"); Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); @@ -52,8 +69,5 @@ public void CanResumeCircuitAfterDisconnection() Assert.NotEqual(previousText, newText); Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); - - // Can dispatch events after reconnect - Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor index 63cbeed60025..4e499a486453 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor @@ -15,10 +15,9 @@