diff --git a/src/Components/Authorization/src/AuthorizeViewCore.cs b/src/Components/Authorization/src/AuthorizeViewCore.cs index e63eae4f11a0..85eae5cdb812 100644 --- a/src/Components/Authorization/src/AuthorizeViewCore.cs +++ b/src/Components/Authorization/src/AuthorizeViewCore.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Components.Authorization; /// /// A base class for components that display differing content depending on the user's authorization status. /// +[CacheBoundaryPolicy(Disallow = true, VaryBy = CacheBoundaryVaryBy.User)] public abstract class AuthorizeViewCore : ComponentBase { private AuthenticationState? currentAuthenticationState; diff --git a/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs b/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs new file mode 100644 index 000000000000..54fcdcab94a7 --- /dev/null +++ b/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs @@ -0,0 +1,33 @@ +// 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; + +/// +/// Specifies how a component interacts with an enclosing CacheBoundary. The component is treated +/// as a "hole": it runs its own lifecycle on every request, while its parameters are captured once +/// and replayed unchanged on cache hits. Set to instead include it in the +/// cached output when the boundary varies by the given dimensions. +/// +/// parameters are not supported on hole components, since a captured +/// fragment would be frozen to the first render; encountering one throws an . +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public sealed class CacheBoundaryPolicyAttribute : Attribute +{ + /// + /// Gets or sets whether using this component inside a cache boundary throws an + /// . Use this for components whose parameters would not + /// behave correctly if captured once and replayed. Suppressed when the boundary varies by all + /// dimensions in . Defaults to . + /// + public bool Disallow { get; set; } + + /// + /// Gets or sets the vary-by dimensions that, when all active on the enclosing CacheBoundary, + /// include the component in the cached output instead of treating it as a hole (or throwing + /// when is set). Defaults to . + /// + public CacheBoundaryVaryBy VaryBy { get; set; } +} diff --git a/src/Components/Components/src/CacheBoundaryVaryBy.cs b/src/Components/Components/src/CacheBoundaryVaryBy.cs new file mode 100644 index 000000000000..993f60ab57cd --- /dev/null +++ b/src/Components/Components/src/CacheBoundaryVaryBy.cs @@ -0,0 +1,48 @@ +// 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; + +/// +/// Describes which vary-by dimensions are active on an enclosing CacheBoundary. +/// Used as flags in to express conditional +/// cache exclusion, and internally to communicate the active dimensions to the renderer. +/// +[Flags] +public enum CacheBoundaryVaryBy +{ + /// + /// No vary-by dimensions. + /// + None = 0, + + /// + /// Vary by query string parameters. + /// + Query = 1 << 0, + + /// + /// Vary by route parameters. + /// + Route = 1 << 1, + + /// + /// Vary by HTTP header values. + /// + Header = 1 << 2, + + /// + /// Vary by cookie values. + /// + Cookie = 1 << 3, + + /// + /// Vary by the authenticated user. + /// + User = 1 << 4, + + /// + /// Vary by culture. + /// + Culture = 1 << 5, +} diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 85745bd517ff..6b57d577a217 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs b/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs index c12dbe12533d..95c88661c202 100644 --- a/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs +++ b/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs @@ -126,17 +126,8 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta private static ReadOnlySpan ResolveKeySpan(object? key) { - if (key is IFormattable formattable) - { - var keyString = formattable.ToString("", CultureInfo.InvariantCulture); - return keyString.AsSpan(); - } - else if (key is IConvertible convertible) - { - var keyString = convertible.ToString(CultureInfo.InvariantCulture); - return keyString.AsSpan(); - } - return default; + var formatted = ComponentKeyHelper.FormatSerializableKey(key); + return formatted.AsSpan(); } private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer, int? size = null) @@ -154,7 +145,7 @@ private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer, int? private static object? GetSerializableKey(ComponentState componentState) { var componentKey = componentState.GetComponentKey(); - if (componentKey != null && IsSerializableKey(componentKey)) + if (componentKey != null && ComponentKeyHelper.IsSerializableKey(componentKey)) { return componentKey; } @@ -195,20 +186,4 @@ private static string GetParentComponentType(ComponentState componentState) private static byte[] KeyFactory((string parentComponentType, string componentType, string propertyName) parts) => SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName))); - - private static bool IsSerializableKey(object key) - { - if (key == null) - { - return false; - } - var keyType = key.GetType(); - var result = Type.GetTypeCode(keyType) != TypeCode.Object - || keyType == typeof(Guid) - || keyType == typeof(DateTimeOffset) - || keyType == typeof(DateOnly) - || keyType == typeof(TimeOnly); - - return result; - } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index e3e49d6cc168..7a59c498809a 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,4 +1,18 @@ #nullable enable +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.CacheBoundaryPolicyAttribute() -> void +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.Disallow.get -> bool +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.Disallow.set -> void +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.VaryBy.get -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.VaryBy.set -> void +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Cookie = 8 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Culture = 32 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Header = 4 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.None = 0 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Query = 1 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Route = 2 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy +Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.User = 16 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy abstract Microsoft.AspNetCore.Components.CascadingParameterSubscription.Dispose() -> void abstract Microsoft.AspNetCore.Components.CascadingParameterSubscription.GetCurrentValue() -> object? Microsoft.AspNetCore.Components.CascadingParameterSubscription diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs new file mode 100644 index 000000000000..e4a7e5713847 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; + +namespace Microsoft.AspNetCore.Components; + +/// +/// A component that caches the rendered HTML of its child content during +/// server-side rendering (SSR). On cache hit, child components are not +/// instantiated or rendered. +/// +public sealed class CacheBoundary : IComponent, IDisposable +{ + private RenderHandle _renderHandle; + + /// + /// Gets or sets the content to be cached. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets an explicit cache key for disambiguation when multiple + /// instances share the same component ancestor. + /// + [Parameter] + public string? CacheKey { get; set; } + + /// + /// Gets or sets whether caching is enabled. Defaults to true. + /// + [Parameter] + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets how long after creation the cache entry should be evicted. + /// + [Parameter] + public TimeSpan? ExpiresAfter { get; set; } + + /// + /// Gets or sets the absolute when the cache entry should be evicted. + /// + [Parameter] + public DateTimeOffset? ExpiresOn { get; set; } + + /// + /// Gets or sets how long after last access the cache entry should be evicted. + /// Not supported when the cache boundary store uses HybridCache. + /// + [Parameter] + public TimeSpan? ExpiresSliding { get; set; } + + /// + /// Gets or sets the policy for the cache entry. + /// Not supported when the cache boundary store uses HybridCache. + /// + [Parameter] + public CacheItemPriority? Priority { get; set; } + + /// + /// Gets or sets a comma-separated list of query string parameter names to vary the cache by. + /// Use "*" to vary by all query string parameters. + /// + [Parameter] + public string? VaryByQuery { get; set; } + + /// + /// Gets or sets a comma-separated list of route parameter names to vary the cache by. + /// + [Parameter] + public string? VaryByRoute { get; set; } + + /// + /// Gets or sets a comma-separated list of HTTP header names to vary the cache by. + /// + [Parameter] + public string? VaryByHeader { get; set; } + + /// + /// Gets or sets a comma-separated list of cookie names to vary the cache by. + /// + [Parameter] + public string? VaryByCookie { get; set; } + + /// + /// Gets or sets whether to vary the cache by the authenticated user identity. + /// + [Parameter] + public bool? VaryByUser { get; set; } + + /// + /// Gets or sets whether to vary the cache by the current culture. + /// + [Parameter] + public bool? VaryByCulture { get; set; } + + /// + /// Gets or sets a custom string value to vary the cache by. + /// + [Parameter] + public string? VaryBy { get; set; } + + [Inject] internal CacheBoundaryService? CacheService { get; set; } + [CascadingParameter] internal HttpContext? HttpContext { get; set; } + internal Func? TreePositionKeyFactory { get; set; } + internal string? TreePositionKey => TreePositionKeyFactory?.Invoke(); + + internal bool IsInStreamingContext { get; set; } + + // The per-render coordination state produced by CacheBoundaryService. Null when caching is inactive + // for this render; the renderer reads it to drive capture. + internal CacheBoundaryRenderState? RenderState { get; private set; } + + /// + void IComponent.Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + async Task IComponent.SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + RenderState = HttpContext is { } httpContext && CacheService is { } cacheService + ? await cacheService.PrepareAsync(this, httpContext) + : null; + + _renderHandle.Render(BuildRenderTree); + } + + private void BuildRenderTree(RenderTreeBuilder builder) + { + var content = RenderState?.Content ?? ChildContent; + content?.Invoke(builder); + } + + /// + public void Dispose() + { + if (RenderState is { } state) + { + CacheBoundaryService.OnBoundaryDisposed(state); + } + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs new file mode 100644 index 000000000000..9bfca58653c2 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs @@ -0,0 +1,291 @@ +// 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.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal static class CacheBoundaryKeyResolver +{ + private static readonly ConcurrentDictionary _sortedNamesByRawValue = new(StringComparer.Ordinal); + + static CacheBoundaryKeyResolver() + { + if (HotReloadManager.IsSupported) + { + HotReloadManager.Default.OnDeltaApplied += _sortedNamesByRawValue.Clear; + } + } + + internal static string ComputeKey(CacheBoundary cacheBoundary, HttpContext httpContext) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + AppendLengthPrefixedString(hash, cacheBoundary.TreePositionKey ?? ""); + + if (cacheBoundary.CacheKey is not null) + { + AppendString(hash, "||CacheKey||"); + AppendLengthPrefixedString(hash, cacheBoundary.CacheKey); + } + + var request = httpContext.Request; + if (cacheBoundary.VaryBy is { } varyBy) + { + AppendString(hash, "||VaryBy||"); + AppendLengthPrefixedString(hash, varyBy); + } + + if (!string.IsNullOrEmpty(cacheBoundary.VaryByQuery)) + { + if (cacheBoundary.VaryByQuery.Trim() is "*") + { + AppendAllQueryValues(hash, request); + } + else + { + AppendDelimitedQueryValues(hash, cacheBoundary.VaryByQuery, request); + } + } + + if (!string.IsNullOrEmpty(cacheBoundary.VaryByRoute)) + { + AppendDelimitedRouteValues(hash, cacheBoundary.VaryByRoute, request); + } + + if (!string.IsNullOrEmpty(cacheBoundary.VaryByHeader)) + { + AppendDelimitedHeaderValues(hash, cacheBoundary.VaryByHeader, request); + } + + if (!string.IsNullOrEmpty(cacheBoundary.VaryByCookie)) + { + AppendDelimitedCookieValues(hash, cacheBoundary.VaryByCookie, request); + } + + if (cacheBoundary.VaryByUser is true) + { + AppendString(hash, "||VaryByUser||"); + AppendUserIdentity(hash, httpContext.User); + } + + if (cacheBoundary.VaryByCulture is true) + { + AppendString(hash, "||VaryByCulture||"); + AppendLengthPrefixedString(hash, CultureInfo.CurrentCulture.Name); + AppendLengthPrefixedString(hash, CultureInfo.CurrentUICulture.Name); + } + + Span hashOutput = stackalloc byte[SHA256.HashSizeInBytes]; + var hashSucceeded = hash.TryGetHashAndReset(hashOutput, out _); + Debug.Assert(hashSucceeded); + return Convert.ToBase64String(hashOutput); + } + + private static void AppendDelimitedQueryValues(IncrementalHash hash, string separatedValues, HttpRequest request) + { + AppendString(hash, "||"); + AppendString(hash, "VaryByQuery"); + AppendString(hash, "("); + + foreach (var nameString in CollectSortedNames(separatedValues)) + { + AppendNameStringValues(hash, nameString, request.Query[nameString]); + } + + AppendString(hash, ")"); + } + + private static void AppendDelimitedRouteValues(IncrementalHash hash, string separatedValues, HttpRequest request) + { + AppendString(hash, "||"); + AppendString(hash, "VaryByRoute"); + AppendString(hash, "("); + + foreach (var nameString in CollectSortedNames(separatedValues)) + { + var value = request.RouteValues[nameString]?.ToString(); + if (value is null) + { + continue; + } + AppendString(hash, "||"); + AppendString(hash, nameString); + AppendString(hash, "||"); + AppendLengthPrefixedString(hash, value); + } + + AppendString(hash, ")"); + } + + private static void AppendDelimitedHeaderValues(IncrementalHash hash, string separatedValues, HttpRequest request) + { + AppendString(hash, "||"); + AppendString(hash, "VaryByHeader"); + AppendString(hash, "("); + + foreach (var nameString in CollectSortedNames(separatedValues)) + { + AppendNameStringValues(hash, nameString, request.Headers[nameString]); + } + + AppendString(hash, ")"); + } + + private static void AppendDelimitedCookieValues(IncrementalHash hash, string separatedValues, HttpRequest request) + { + AppendString(hash, "||"); + AppendString(hash, "VaryByCookie"); + AppendString(hash, "("); + + foreach (var nameString in CollectSortedNames(separatedValues)) + { + var value = request.Cookies[nameString]; + if (value is null) + { + continue; + } + AppendString(hash, "||"); + AppendString(hash, nameString); + AppendString(hash, "||"); + AppendLengthPrefixedString(hash, value); + } + + AppendString(hash, ")"); + } + + private static string[] CollectSortedNames(string separatedValues) + => _sortedNamesByRawValue.GetOrAdd(separatedValues, static raw => + { + var names = new List(); + foreach (var segment in raw.AsSpan().Split(',')) + { + var name = raw.AsSpan()[segment].Trim(); + if (!name.IsEmpty) + { + names.Add(name.ToString()); + } + } + + names.Sort(StringComparer.Ordinal); + return names.ToArray(); + }); + + private static void AppendAllQueryValues(IncrementalHash hash, HttpRequest request) + { + AppendString(hash, "||"); + AppendString(hash, "VaryByQuery"); + AppendString(hash, "(*)"); + + var names = new List(request.Query.Count); + foreach (var pair in request.Query) + { + names.Add(pair.Key); + } + + names.Sort(StringComparer.Ordinal); + + foreach (var name in names) + { + AppendNameStringValues(hash, name, request.Query[name]); + } + } + + private static void AppendUserIdentity(IncrementalHash hash, ClaimsPrincipal user) + { + var identity = user.Identity; + var isAuthenticated = identity?.IsAuthenticated == true; + + AppendLengthPrefixedString(hash, isAuthenticated ? "1" : "0"); + AppendLengthPrefixedString(hash, identity?.AuthenticationType ?? ""); + + if (!isAuthenticated) + { + // Anonymous: nothing more to mix in. + AppendLengthPrefixedString(hash, "anonymous"); + return; + } + + var nameIdClaim = user.FindFirst(ClaimTypes.NameIdentifier); + if (nameIdClaim is not null && !string.IsNullOrEmpty(nameIdClaim.Value)) + { + AppendLengthPrefixedString(hash, "nameid"); + AppendLengthPrefixedString(hash, nameIdClaim.Value); + AppendLengthPrefixedString(hash, nameIdClaim.Issuer); + return; + } + + AppendLengthPrefixedString(hash, "claims"); + AppendLengthPrefixedString(hash, identity?.Name ?? ""); + foreach (var claim in user.Claims) + { + AppendLengthPrefixedString(hash, claim.Type); + AppendLengthPrefixedString(hash, claim.Value); + AppendLengthPrefixedString(hash, claim.Issuer); + } + } + + private static void AppendNameStringValues(IncrementalHash hash, string name, StringValues values) + { + if (values.Count == 0) + { + return; + } + + AppendString(hash, "||"); + AppendLengthPrefixedString(hash, name); + AppendInt32(hash, values.Count); + foreach (var value in values) + { + AppendLengthPrefixedString(hash, value ?? ""); + } + } + + private static void AppendInt32(IncrementalHash hash, int value) + { + Span buffer = stackalloc byte[4]; + System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(buffer, value); + hash.AppendData(buffer); + } + + private static void AppendLengthPrefixedString(IncrementalHash hash, string value) + { + var byteCount = Encoding.UTF8.GetByteCount(value); + Span lengthPrefix = stackalloc byte[4]; + System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(lengthPrefix, byteCount); + hash.AppendData(lengthPrefix); + AppendString(hash, value); + } + + private static void AppendString(IncrementalHash hash, string? value) + { + if (string.IsNullOrEmpty(value)) + { + hash.AppendData("\0"u8); + return; + } + + var byteCount = Encoding.UTF8.GetByteCount(value); + const int stackAllocThreshold = 256; + byte[]? rented = null; + var buffer = byteCount <= stackAllocThreshold + ? stackalloc byte[stackAllocThreshold] + : (rented = System.Buffers.ArrayPool.Shared.Rent(byteCount)); + + var written = Encoding.UTF8.GetBytes(value, buffer); + hash.AppendData(buffer[..written]); + + if (rented is not null) + { + System.Buffers.ArrayPool.Shared.Return(rented); + } + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryRenderState.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryRenderState.cs new file mode 100644 index 000000000000..a537da3012c2 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryRenderState.cs @@ -0,0 +1,27 @@ +// 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.Endpoints; + +internal sealed class CacheBoundaryRenderState +{ + public CacheBoundaryRenderState(string key, CacheBoundaryVaryBy varyBy) + { + Key = key; + VaryBy = varyBy; + } + + public string Key { get; } + + public CacheBoundaryVaryBy VaryBy { get; } + + public RenderFragment? Content { get; set; } + + public bool IsCacheHit { get; set; } + + public TaskCompletionSource? CaptureCompletion { get; set; } + + public Task? PendingStoreTask { get; set; } + + public CacheBoundaryTextWriter? ActiveWriter { get; set; } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryService.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryService.cs new file mode 100644 index 000000000000..c99e7151ad23 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryService.cs @@ -0,0 +1,363 @@ +// 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.Concurrent; +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed partial class CacheBoundaryService : IDisposable +{ + private static readonly object _inFlightResolutionsItemKey = new(); + + private static readonly JsonSerializerOptions _jsonOptions = ServerComponentSerializationSettings.JsonSerializationOptions; + private static readonly ComponentParametersTypeCache _parametersTypeCache = new(); + private static readonly ConcurrentDictionary _policyByComponentType = new(); + private readonly ICacheBoundaryStore _store; + private readonly ILogger _logger; + + static CacheBoundaryService() + { + if (HotReloadManager.IsSupported) + { + HotReloadManager.Default.OnDeltaApplied += _policyByComponentType.Clear; + } + } + + public CacheBoundaryService(ICacheBoundaryStore store, ILoggerFactory loggerFactory) + { + _store = store; + _logger = loggerFactory.CreateLogger(); + + if (HotReloadManager.IsSupported) + { + HotReloadManager.Default.OnDeltaApplied += _store.Clear; + } + } + + public void Dispose() + { + if (HotReloadManager.IsSupported) + { + HotReloadManager.Default.OnDeltaApplied -= _store.Clear; + } + } + + public static bool IsHoleComponent(Type componentType, CacheBoundaryVaryBy varyBy) + { + var attr = _policyByComponentType.GetOrAdd(componentType, static type => type.GetCustomAttribute(inherit: true)); + + if (attr is null) + { + return false; + } + + var varyByMatches = attr.VaryBy != CacheBoundaryVaryBy.None && (attr.VaryBy & varyBy) == attr.VaryBy; + + if (attr.Disallow && !varyByMatches) + { + throw new InvalidOperationException( + $"Component '{componentType.FullName}' cannot be used inside a CacheBoundary because its output depends on per-request state ([CacheBoundaryPolicy(Disallow = true, VaryBy = {attr.VaryBy})]) that cannot be safely cached and replayed. " + + (attr.VaryBy != CacheBoundaryVaryBy.None + ? $"To fix this, configure the boundary to vary by {attr.VaryBy}, or move the component outside the CacheBoundary." + : "To fix this, move the component outside the CacheBoundary.")); + } + + return !varyByMatches; + } + + public async Task PrepareAsync(CacheBoundary boundary, HttpContext httpContext) + { + // Skip cache if method is not GET, caching is disabled, or the boundary is rendered inside a + // streaming render context (not yet supported). + if (!boundary.Enabled || !HttpMethods.IsGet(httpContext.Request.Method) || boundary.IsInStreamingContext) + { + return null; + } + + var key = CacheBoundaryKeyResolver.ComputeKey(boundary, httpContext); + var state = new CacheBoundaryRenderState(key, GetVaryBy(boundary)) + { + Content = boundary.ChildContent, + }; + + // Handles multiple CacheBoundary instances in the same request resolving to the same key (e.g. a + // component rendered twice, or a loop). + var resolutions = GetInFlightResolutions(httpContext); + if (resolutions.TryGetValue(key, out var existingResolution)) + { + await ApplyDuplicateResolutionAsync(state, key, existingResolution); + return state; + } + + var resolution = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + resolutions[key] = resolution.Task; + + await ResolveOrBeginCreateAsync(boundary, state, resolution, httpContext.RequestAborted); + return state; + } + + public static void ThrowIfNestedInsideCapturingBoundary(TextWriter output) + { + if (output is CacheBoundaryTextWriter { IsCapturing: true }) + { + throw new InvalidOperationException( + "A CacheBoundary cannot be nested inside another CacheBoundary. The inner boundary's output " + + "would be frozen into the outer cache entry and replayed on later requests, which is unsafe for " + + "per-request content such as antiforgery tokens, authentication-dependent output, or interactive " + + "component markers. Move the CacheBoundary so it is not nested inside another one."); + } + } + + public static bool TryBeginWrite(CacheBoundaryRenderState? state, CacheBoundary boundary, TextWriter output, out TextWriter wrappedOutput) + { + if (state is { CaptureCompletion: not null }) + { + var captureWriter = new CacheBoundaryTextWriter(output, state.VaryBy); + captureWriter.StartCapture(); + state.ActiveWriter = captureWriter; + wrappedOutput = captureWriter; + return true; + } + + if (output is not CacheBoundaryTextWriter) + { + var validationWriter = new CacheBoundaryTextWriter(output, GetVaryBy(boundary)); + validationWriter.StartValidation(); + wrappedOutput = validationWriter; + return true; + } + + wrappedOutput = output; + return false; + } + + public void EndCapture(CacheBoundaryRenderState? state, bool completed) + { + var writer = state?.ActiveWriter; + if (state is null || writer is null) + { + return; + } + + var completion = state.CaptureCompletion; + var pending = state.PendingStoreTask; + + try + { + if (!completed) + { + completion?.TrySetCanceled(); + return; + } + writer.StopCapture(); + completion?.TrySetResult(writer.GetJson()); + } + catch (Exception ex) + { + completion?.TrySetException(ex); + } + finally + { + state.ActiveWriter = null; + state.CaptureCompletion = null; + state.PendingStoreTask = null; + if (pending is not null) + { + _ = ObserveCacheStorePersistAsync(state.Key, pending); + } + } + } + + public static void OnBoundaryDisposed(CacheBoundaryRenderState state) + { + var completion = state.CaptureCompletion; + if (completion is not null && !completion.Task.IsCompleted) + { + completion.TrySetCanceled(); + } + + state.ActiveWriter = null; + state.CaptureCompletion = null; + state.PendingStoreTask = null; + } + + public static CacheBoundaryVaryBy GetVaryBy(CacheBoundary boundary) + { + var result = CacheBoundaryVaryBy.None; + + if (!string.IsNullOrEmpty(boundary.VaryByQuery)) + { + result |= CacheBoundaryVaryBy.Query; + } + + if (!string.IsNullOrEmpty(boundary.VaryByRoute)) + { + result |= CacheBoundaryVaryBy.Route; + } + + if (!string.IsNullOrEmpty(boundary.VaryByHeader)) + { + result |= CacheBoundaryVaryBy.Header; + } + + if (!string.IsNullOrEmpty(boundary.VaryByCookie)) + { + result |= CacheBoundaryVaryBy.Cookie; + } + + if (boundary.VaryByUser is true) + { + result |= CacheBoundaryVaryBy.User; + } + + if (boundary.VaryByCulture is true) + { + result |= CacheBoundaryVaryBy.Culture; + } + + return result; + } + + private async Task ApplyDuplicateResolutionAsync(CacheBoundaryRenderState state, string key, Task resolution) + { + string? cachedJson; + try + { + cachedJson = await resolution; + } + catch + { + Log.DuplicateBoundaryRenderingFresh(_logger, key); + return; + } + + if (cachedJson is not null && DeserializeCachedContent(cachedJson) is { } cachedContent) + { + state.IsCacheHit = true; + state.Content = cachedContent; + } + else + { + Log.DuplicateBoundaryRenderingFresh(_logger, key); + } + } + + private async Task ResolveOrBeginCreateAsync(CacheBoundary boundary, CacheBoundaryRenderState state, TaskCompletionSource resolution, CancellationToken cancellationToken) + { + var captureCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factoryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.CaptureCompletion = captureCompletion; + + var options = new CacheStoreOptions + { + ExpiresAfter = boundary.ExpiresAfter, + ExpiresOn = boundary.ExpiresOn, + ExpiresSliding = boundary.ExpiresSliding, + Priority = boundary.Priority, + }; + + try + { + var inflight = _store.GetOrCreateAsync( + state.Key, + async ct => + { + factoryStarted.TrySetResult(); + return await captureCompletion.Task.WaitAsync(ct); + }, + options, + cancellationToken).AsTask(); + + // Wait for whichever happens first: the cached value is available or our factory got invoked (we're the creator). + var firstFinished = await Task.WhenAny(inflight, factoryStarted.Task); + if (firstFinished == inflight) + { + // Cache hit: we are not the creator, so clear the capture reservation so TryBeginWrite does + // not capture this boundary's output. Duplicates reuse this same cached content. + state.CaptureCompletion = null; + state.IsCacheHit = true; + var cachedJson = await inflight; + state.Content = DeserializeCachedContent(cachedJson) ?? boundary.ChildContent; + resolution.TrySetResult(cachedJson); + } + else + { + // We are the creator: signal any same-key duplicates in this request to render fresh. + state.PendingStoreTask = inflight; + resolution.TrySetResult(null); + } + } + catch (Exception ex) + { + resolution.TrySetException(ex); + throw; + } + } + + private RenderFragment? DeserializeCachedContent(string? json) + { + if (string.IsNullOrEmpty(json)) + { + return null; + } + + try + { + var payload = JsonSerializer.Deserialize(json, _jsonOptions); + if (payload is null || payload.Nodes.Count == 0) + { + return null; + } + return RenderFragmentSerializer.Deserialize(payload.Nodes, _jsonOptions, _parametersTypeCache); + } + catch (Exception ex) + { + Log.RestoreFromCacheFailed(_logger, ex); + return null; + } + } + + private async Task ObserveCacheStorePersistAsync(string key, Task pending) + { + try + { + await pending; + } + catch (OperationCanceledException) + { + // Request aborted while persisting; nothing to log. + } + catch (Exception ex) + { + Log.PersistFailed(_logger, key, ex); + } + } + + private static Dictionary> GetInFlightResolutions(HttpContext httpContext) + { + if (httpContext.Items[_inFlightResolutionsItemKey] is not Dictionary> resolutions) + { + resolutions = new Dictionary>(StringComparer.Ordinal); + httpContext.Items[_inFlightResolutionsItemKey] = resolutions; + } + + return resolutions; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Another CacheBoundary in the same request is already creating cache key '{Key}'. Rendering this instance fresh to avoid deadlocking on the in-flight creator; it will share the cached entry on subsequent requests.", EventName = "DuplicateBoundaryRenderingFresh")] + public static partial void DuplicateBoundaryRenderingFresh(ILogger logger, string key); + + [LoggerMessage(2, LogLevel.Warning, "Failed to restore CacheBoundary from cached data. Falling back to fresh render.", EventName = "RestoreFromCacheFailed")] + public static partial void RestoreFromCacheFailed(ILogger logger, Exception exception); + + [LoggerMessage(3, LogLevel.Warning, "Failed to persist CacheBoundary entry for key '{Key}'.", EventName = "PersistFailed")] + public static partial void PersistFailed(ILogger logger, string key, Exception exception); + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs new file mode 100644 index 000000000000..1b60fde03aa1 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs @@ -0,0 +1,15 @@ +// 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.Endpoints; + +internal interface ICacheBoundaryStore : IDisposable +{ + ValueTask GetOrCreateAsync( + string key, + Func> factory, + CacheStoreOptions options, + CancellationToken cancellationToken); + + void Clear() { } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStoreOptions.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStoreOptions.cs new file mode 100644 index 000000000000..0c31eb3eb7a2 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStoreOptions.cs @@ -0,0 +1,9 @@ +// 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.Endpoints; + +internal sealed class CacheBoundaryStoreOptions +{ + public bool UseHybridCache { get; set; } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheStoreOptions.cs b/src/Components/Endpoints/src/CacheBoundary/CacheStoreOptions.cs new file mode 100644 index 000000000000..32ac38e61f72 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheStoreOptions.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Memory; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal readonly struct CacheStoreOptions +{ + public TimeSpan? ExpiresAfter { get; init; } + public DateTimeOffset? ExpiresOn { get; init; } + public TimeSpan? ExpiresSliding { get; init; } + public CacheItemPriority? Priority { get; init; } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/HybridCacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/HybridCacheBoundaryStore.cs new file mode 100644 index 000000000000..7adab747f965 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/HybridCacheBoundaryStore.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Hybrid; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed class HybridCacheBoundaryStore : ICacheBoundaryStore +{ + private const string CacheBoundaryTag = "Microsoft.AspNetCore.Components.Endpoints.CacheBoundary"; + private static readonly string[] _tags = [CacheBoundaryTag]; + + private readonly HybridCache _hybridCache; + + public HybridCacheBoundaryStore(HybridCache hybridCache) + { + _hybridCache = hybridCache; + } + + public async ValueTask GetOrCreateAsync( + string key, + Func> factory, + CacheStoreOptions options, + CancellationToken cancellationToken) + { + var hybridOptions = BuildHybridOptions(options); + + // Loops only to re-elect a creator when the in-flight creator's render is cancelled (its + // request is aborted, cancelling the boundary's captureCompletion) while this caller is still + // alive. HybridCache removes the faulted stampede entry, so a retry re-checks the cache and, + // if necessary, re-elects a new creator (possibly this caller). A genuine factory exception is + // not an OperationCanceledException, so it still propagates. + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await _hybridCache.GetOrCreateAsync(key, factory, static (state, ct) => state(ct), hybridOptions, _tags, cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + continue; + } + } + } + + public void Clear() + { + _hybridCache.RemoveByTagAsync(CacheBoundaryTag).AsTask().GetAwaiter().GetResult(); + } + + private static HybridCacheEntryOptions BuildHybridOptions(CacheStoreOptions options) + { + if (options.ExpiresSliding.HasValue) + { + throw new NotSupportedException( + $"{nameof(CacheBoundary)}.{nameof(CacheBoundary.ExpiresSliding)} is not supported when the cache boundary store uses HybridCache. " + + $"Use {nameof(CacheBoundary.ExpiresAfter)} or {nameof(CacheBoundary.ExpiresOn)} for absolute expiration."); + } + + if (options.Priority.HasValue) + { + throw new NotSupportedException( + $"{nameof(CacheBoundary)}.{nameof(CacheBoundary.Priority)} is not supported when the cache boundary store uses HybridCache. " + + $"Remove the {nameof(CacheBoundary.Priority)} parameter or switch to the in-memory cache boundary store."); + } + + var absolute = options.ExpiresOn.HasValue + ? options.ExpiresOn.Value - DateTimeOffset.UtcNow + : options.ExpiresAfter ?? RazorComponentsServiceOptions.DefaultCacheBoundaryExpiration; + + if (absolute < TimeSpan.Zero) + { + absolute = TimeSpan.Zero; + } + + return new HybridCacheEntryOptions + { + Expiration = absolute, + }; + } + + public void Dispose() + { + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs new file mode 100644 index 000000000000..0a99282bf878 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs @@ -0,0 +1,136 @@ +// 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.Concurrent; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed partial class MemoryCacheBoundaryStore : ICacheBoundaryStore +{ + private readonly MemoryCache _cache; + private readonly ILogger _logger; + private readonly ConcurrentDictionary> _pending = new(StringComparer.Ordinal); + + public MemoryCacheBoundaryStore(IOptions options, ILogger logger) + { + _cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = options.Value.CacheBoundarySizeLimit, + }); + _logger = logger; + } + + public async ValueTask GetOrCreateAsync( + string key, + Func> factory, + CacheStoreOptions options, + CancellationToken cancellationToken) + { + // Loops only to re-elect a creator when an in-flight creator is cancelled (e.g. its request + // is aborted) while this caller is still alive. Each iteration either returns a cached value, + // observes another caller's result, or becomes the creator itself. + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_cache.TryGetValue(key, out var existing) && existing is not null) + { + return existing; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = _pending.GetOrAdd(key, tcs.Task); + if (!ReferenceEquals(pending, tcs.Task)) + { + // Another caller is already creating this entry; observe their result. + try + { + return await pending.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The creator was cancelled but this request is still alive. The creator removes + // its pending entry before faulting, so loop back to re-check the cache and, if + // necessary, re-elect a new creator (possibly this caller). A genuine factory + // exception is not an OperationCanceledException, so it still propagates here. + continue; + } + } + + try + { + var json = await factory(cancellationToken); + StoreEntry(key, json, options); + tcs.SetResult(json); + return json; + } + catch (Exception ex) + { + tcs.SetException(ex); + throw; + } + finally + { + _pending.TryRemove(new KeyValuePair>(key, tcs.Task)); + } + } + } + + private void StoreEntry(string key, string json, CacheStoreOptions options) + { + try + { + var entryOptions = new MemoryCacheEntryOptions + { + Size = json.Length * sizeof(char), + }; + + if (options.ExpiresSliding.HasValue) + { + entryOptions.SlidingExpiration = options.ExpiresSliding.Value; + } + + if (options.ExpiresOn.HasValue) + { + entryOptions.AbsoluteExpiration = options.ExpiresOn.Value; + } + else + { + entryOptions.AbsoluteExpirationRelativeToNow = options.ExpiresAfter ?? RazorComponentsServiceOptions.DefaultCacheBoundaryExpiration; + } + + if (options.Priority.HasValue) + { + entryOptions.Priority = options.Priority.Value; + } + + _cache.Set(key, json, entryOptions); + } + catch (Exception ex) + { + // Failing to cache the entry should not fail the request; the value was still + // produced successfully, so log and continue without caching. + // Identical behaviour to HybridCache + Log.StoreEntryFailed(_logger, key, ex); + } + } + + public void Clear() + { + _cache.Clear(); + } + + public void Dispose() + { + _cache.Dispose(); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, "Failed to store CacheBoundary entry for key '{Key}'.", EventName = "StoreEntryFailed")] + public static partial void StoreEntryFailed(ILogger logger, string key, Exception exception); + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/HybridCacheBoundaryStoreServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/HybridCacheBoundaryStoreServiceCollectionExtensions.cs new file mode 100644 index 000000000000..be077fa22446 --- /dev/null +++ b/src/Components/Endpoints/src/DependencyInjection/HybridCacheBoundaryStoreServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for configuring a CacheBoundary store that uses +/// . +/// +public static class HybridCacheBoundaryStoreServiceCollectionExtensions +{ + /// + /// Selects a CacheBoundary store that uses instead of the + /// default store that uses . + /// must be registered separately. The selection is applied additively and is independent of the + /// order in which services are registered. + /// + /// The . + /// The same for chaining. + public static IRazorComponentsBuilder AddHybridCacheBoundaryStore(this IRazorComponentsBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.Configure(static options => options.UseHybridCache = true); + return builder; + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 2119368c9b82..4c5f27223dac 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -74,6 +74,11 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); services.AddTempData(); + services.TryAddSingleton(static sp => + sp.GetRequiredService>().Value.UseHybridCache + ? ActivatorUtilities.CreateInstance(sp) + : ActivatorUtilities.CreateInstance(sp)); + services.TryAddSingleton(); services.TryAddScoped(); services.TryAddCascadingValueSupplier( sp => sp.GetRequiredService().CreateSubscription); diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs index 85e883dde0d9..488f1a5a436d 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs @@ -103,4 +103,23 @@ public TimeSpan TemporaryRedirectionUrlValidityDuration /// Defaults to . /// public TempDataProviderType TempDataProviderType { get; set; } = TempDataProviderType.Cookie; + + /// + /// Gets or sets the maximum size, in bytes, of the memory cache used by + /// for server-side rendering. When the limit is reached, no new entries are cached until + /// existing entries expire. Defaults to 100 MB. A value of 0 configures a zero-byte + /// cache size limit, so entries are not cached. + /// + public long CacheBoundarySizeLimit + { + get => _cacheBoundarySizeLimit; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + _cacheBoundarySizeLimit = value; + } + } + + private long _cacheBoundarySizeLimit = 100_000_000; + internal static readonly TimeSpan DefaultCacheBoundaryExpiration = TimeSpan.FromSeconds(30); } diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj index 2dc1f9e6bb18..bbc56685290e 100644 --- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj +++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj @@ -30,6 +30,7 @@ + @@ -66,6 +67,7 @@ + diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index bf4313eeb383..9c3d34ef3a1e 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,6 +1,41 @@ #nullable enable Microsoft.AspNetCore.Components.Endpoints.BasePath Microsoft.AspNetCore.Components.Endpoints.BasePath.BasePath() -> void +Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.CacheBoundarySizeLimit.get -> long +Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.CacheBoundarySizeLimit.set -> void +Microsoft.AspNetCore.Components.CacheBoundary +Microsoft.AspNetCore.Components.CacheBoundary.CacheBoundary() -> void +Microsoft.AspNetCore.Components.CacheBoundary.CacheKey.get -> string? +Microsoft.AspNetCore.Components.CacheBoundary.CacheKey.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.CacheBoundary.ChildContent.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.Enabled.get -> bool +Microsoft.AspNetCore.Components.CacheBoundary.Enabled.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.ExpiresAfter.get -> System.TimeSpan? +Microsoft.AspNetCore.Components.CacheBoundary.ExpiresAfter.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.ExpiresOn.get -> System.DateTimeOffset? +Microsoft.AspNetCore.Components.CacheBoundary.ExpiresOn.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.ExpiresSliding.get -> System.TimeSpan? +Microsoft.AspNetCore.Components.CacheBoundary.ExpiresSliding.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.Priority.get -> Microsoft.Extensions.Caching.Memory.CacheItemPriority? +Microsoft.AspNetCore.Components.CacheBoundary.Priority.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryBy.get -> string? +Microsoft.AspNetCore.Components.CacheBoundary.VaryBy.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryByCookie.get -> string? +Microsoft.AspNetCore.Components.CacheBoundary.VaryByCookie.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryByCulture.get -> bool? +Microsoft.AspNetCore.Components.CacheBoundary.VaryByCulture.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryByHeader.get -> string? +Microsoft.AspNetCore.Components.CacheBoundary.VaryByHeader.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryByQuery.get -> string? +Microsoft.AspNetCore.Components.CacheBoundary.VaryByQuery.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryByRoute.get -> string? +Microsoft.AspNetCore.Components.CacheBoundary.VaryByRoute.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.VaryByUser.get -> bool? +Microsoft.AspNetCore.Components.CacheBoundary.VaryByUser.set -> void +Microsoft.AspNetCore.Components.CacheBoundary.Dispose() -> void +Microsoft.Extensions.DependencyInjection.HybridCacheBoundaryStoreServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.HybridCacheBoundaryStoreServiceCollectionExtensions.AddHybridCacheBoundaryStore(this Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! builder) -> Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataCookie.get -> Microsoft.AspNetCore.Http.CookieBuilder! Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataCookie.set -> void Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataProviderType.get -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType diff --git a/src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs b/src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs new file mode 100644 index 000000000000..8497328bd0e3 --- /dev/null +++ b/src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.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.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed class CacheBoundaryTextWriter : TextWriter +{ + private readonly TextWriter _innerWriter; + private readonly StringBuilder _buffer = new(); + private readonly List _entries = []; + private bool _capturing; + private bool _validateOnly; + + public CacheBoundaryTextWriter(TextWriter inner, CacheBoundaryVaryBy varyBy) + { + _innerWriter = inner; + VaryBy = varyBy; + } + + public CacheBoundaryVaryBy VaryBy { get; set; } + + public bool IsCapturing => _capturing; + + public bool IsValidationOnly => _validateOnly; + + public override Encoding Encoding => _innerWriter.Encoding; + + public override void Write(char value) + { + _innerWriter.Write(value); + if (_capturing && !_validateOnly) + { + _buffer.Append(value); + } + } + + public override void Write(string? value) + { + _innerWriter.Write(value); + if (_capturing && !_validateOnly) + { + _buffer.Append(value); + } + } + + public void PauseCapture() + { + FlushBuffer(); + _capturing = false; + } + + public void StartCapture() + { + _capturing = true; + } + + public void StartValidation() + { + _capturing = true; + _validateOnly = true; + } + + public void CreateHole(Type componentType, IComponentRenderMode? renderMode, RenderFragmentCapture capture, ILogger renderFragmentSerializationLogger) + { + ThrowIfHoleHasRenderFragmentParameter(componentType, capture); + + RenderTreeNode? holeNode = null; + foreach (var node in RenderFragmentSerializer.SerializeFrames(capture, renderFragmentSerializationLogger)) + { + if (node.Type is "component") + { + holeNode = node; + break; + } + } + + if (holeNode is null) + { + throw new InvalidOperationException( + $"CacheBoundary could not serialize the hole component '{componentType.FullName}' from its parent's render tree."); + } + + // The serializer fills RenderModeName from an inline @rendermode frame. For components that + // declare their render mode via [RenderModeAttribute] instead, the capture has no render-mode + // frame, so patch it from the boundary's runtime render mode. + var renderModeName = RenderFragmentSerializer.GetRenderModeName(renderMode); + if (renderModeName is not null && holeNode.RenderModeName is null) + { + holeNode.RenderModeName = renderModeName; + holeNode.Prerender = RenderFragmentSerializer.GetRenderModePrerender(renderMode); + } + + _entries.Add(CacheCaptureEntry.Hole(holeNode)); + } + + public void StopCapture() + { + _capturing = false; + FlushBuffer(); + } + + // Assembles the cache JSON by walking the recorded entries in render order: markup segments become + // markup nodes and holes contribute the component node serialized at CreateHole time. + public string GetJson() + { + var nodes = new List(_entries.Count); + + foreach (var entry in _entries) + { + nodes.Add(entry.HoleNode ?? new RenderTreeNode { Type = "markup", Content = entry.Markup }); + } + + return JsonSerializer.Serialize( + new SerializedRenderFragment { Nodes = nodes }, + ServerComponentSerializationSettings.JsonSerializationOptions); + } + + // A hole is serialized from its parent's frames, which carry no nested RenderFragment captures, so a + // RenderFragment parameter could not be replayed correctly. Surface an actionable error before the + // generic serializer error fires. + private static void ThrowIfHoleHasRenderFragmentParameter(Type holeComponentType, RenderFragmentCapture capture) + { + foreach (ref readonly var frame in capture.GetCapturedFrames().AsSpan()) + { + if (frame.FrameType is RenderTreeFrameType.Attribute && IsRenderFragmentParameter(frame.AttributeValue)) + { + throw new InvalidOperationException( + $"The [CacheBoundaryPolicy] hole component '{holeComponentType.FullName}' cannot be used inside a CacheBoundary because its RenderFragment parameter '{frame.AttributeName}' would be frozen to the first render's content (a hole's parameters are captured once and replayed). " + + "To fix this, remove the RenderFragment parameter or move the component outside the CacheBoundary."); + } + } + } + + private static bool IsRenderFragmentParameter(object? value) + => value is RenderFragment || (value is Delegate d && d.GetType().IsGenericType && d.GetType().GetGenericTypeDefinition() == typeof(RenderFragment<>)); + + private void FlushBuffer() + { + if (_buffer.Length > 0) + { + _entries.Add(CacheCaptureEntry.MarkupEntry(_buffer.ToString())); + _buffer.Clear(); + } + } +} + +internal readonly struct CacheCaptureEntry +{ + public string? Markup { get; } + public RenderTreeNode? HoleNode { get; } + + private CacheCaptureEntry(string? markup, RenderTreeNode? holeNode) + { + Markup = markup; + HoleNode = holeNode; + } + + public static CacheCaptureEntry MarkupEntry(string markup) + => new(markup, holeNode: null); + + public static CacheCaptureEntry Hole(RenderTreeNode holeNode) + => new(markup: null, holeNode); +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index f76bdc40a236..43774ea5f4a7 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Globalization; using System.Reflection; using System.Reflection.Metadata; using Microsoft.AspNetCore.Components.Endpoints; @@ -17,6 +18,8 @@ internal sealed class EndpointComponentState : ComponentState { private static readonly ConcurrentDictionary _streamRenderingAttributeByComponentType = new(); + private static readonly string _cacheBoundaryTypeName = typeof(CacheBoundary).FullName!; + static EndpointComponentState() { if (HotReloadManager.IsSupported) @@ -26,6 +29,7 @@ static EndpointComponentState() } private readonly EndpointHtmlRenderer _renderer; + public EndpointComponentState(Renderer renderer, int componentId, IComponent component, ComponentState? parentComponentState) : base(renderer, componentId, component, parentComponentState) { @@ -43,6 +47,21 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com var parentEndpointComponentState = (EndpointComponentState?)LogicalParentComponentState; StreamRendering = parentEndpointComponentState?.StreamRendering ?? false; } + + if (component is CacheBoundary cacheBoundary && parentComponentState is not null) + { + // Output caching inside a streaming render context is not yet supported. + cacheBoundary.IsInStreamingContext = StreamRendering; + + var ancestorTypeName = parentComponentState.Component?.GetType().FullName ?? ""; + cacheBoundary.TreePositionKeyFactory = () => + { + var sequence = FindSequenceInParent(parentComponentState, cacheBoundary); + var componentKey = GetComponentKey(); + var keyString = ComponentKeyHelper.FormatSerializableKey(componentKey); + return ComputeTreePositionKey(ancestorTypeName, sequence, keyString); + }; + } } public bool StreamRendering { get; } @@ -67,4 +86,30 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com /// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection. /// public static void UpdateApplication(Type[]? _) => _streamRenderingAttributeByComponentType.Clear(); + + private static string ComputeTreePositionKey(string ancestorTypeName, int sequence, string? keyString) + { + return string.Concat( + ancestorTypeName, ".", + _cacheBoundaryTypeName, "#", + sequence.ToString(CultureInfo.InvariantCulture), + keyString is not null ? "." : "", + keyString); + } + + // We need this calculation because otherwise multiple CacheBoundary components under the same parent would have + // the same key and would point to the same cache entry, which is incorrect. + private int FindSequenceInParent(ComponentState parentState, CacheBoundary target) + { + var frames = _renderer.GetRenderTreeFrames(parentState.ComponentId); + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component && ReferenceEquals(frame.Component, target)) + { + return frame.Sequence; + } + } + return 0; + } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index aeacd271e17c..9989f912d3f6 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -271,7 +272,61 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo _visitedComponentIdsInCurrentStreamingBatch?.Add(componentId); var componentState = (EndpointComponentState)GetComponentState(componentId); + + if (componentState.Component is CacheBoundary cacheBoundary) + { + CacheBoundaryService.ThrowIfNestedInsideCapturingBoundary(output); + + var renderState = cacheBoundary.RenderState; + + if (renderState?.IsCacheHit == true) + { + // Cache hit: the boundary's render tree already holds the cached content. + base.WriteComponentHtml(componentId, output); + return; + } + + if (CacheBoundaryService.TryBeginWrite(renderState, cacheBoundary, output, out var wrappedOutput)) + { + var captureCompletedSuccessfully = false; + try + { + base.WriteComponentHtml(componentId, wrappedOutput); + captureCompletedSuccessfully = true; + } + finally + { + GetCacheBoundaryService().EndCapture(renderState, captureCompletedSuccessfully); + } + return; + } + } + var renderBoundaryMarkers = allowBoundaryMarkers && componentState.StreamRendering; + var captureWriter = output as CacheBoundaryTextWriter; + var pausedCapture = false; + if (captureWriter is not null && captureWriter.IsCapturing && (CacheBoundaryService.IsHoleComponent(componentState.Component.GetType(), captureWriter.VaryBy) || renderBoundaryMarkers)) + { + pausedCapture = true; + captureWriter.PauseCapture(); + + // A validation-only writer (a disabled boundary) records nothing; the hole-policy error + // already surfaced in the condition above. Only a real capture records the hole. + if (!captureWriter.IsValidationOnly) + { + // An interactive hole is wrapped in an SSRRenderModeBoundary that owns the inner component + // type and render mode; a plain [CacheBoundaryPolicy] hole is the component itself with no + // render mode. + var holeBoundary = componentState.Component as SSRRenderModeBoundary; + var holeComponentType = holeBoundary?.ComponentType ?? componentState.Component.GetType(); + + var holeCapture = TryCaptureHoleParameterFrames(componentState) + ?? throw new InvalidOperationException( + $"CacheBoundary could not locate the hole component '{holeComponentType.FullName}' in its parent's render tree."); + + captureWriter.CreateHole(holeComponentType, holeBoundary?.RenderMode, holeCapture, GetRenderFragmentSerializationLogger()); + } + } ComponentEndMarker? endMarkerOrNull = default; @@ -320,8 +375,52 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo output.Write(serializedEndRecord); output.Write("-->"); } + + if (pausedCapture) + { + captureWriter!.StartCapture(); + } } + // Captures frames for a hole component from its parent's render tree + private RenderFragmentCapture? TryCaptureHoleParameterFrames(EndpointComponentState holeComponentState) + { + if (holeComponentState.ParentComponentState is not { } parentComponentState) + { + return null; + } + + var frames = GetRenderTreeFrames(parentComponentState.ComponentId); + var array = frames.Array; + for (var i = 0; i < frames.Count; i++) + { + ref readonly var frame = ref array[i]; + if (frame.FrameType is RenderTreeFrameType.Component && ReferenceEquals(frame.Component, holeComponentState.Component)) + { + var length = frame.ComponentSubtreeLength; + var slice = new RenderTreeFrame[length]; + Array.Copy(array, i, slice, 0, length); + return new RenderFragmentCapture(slice); + } + } + + return null; + } + + private ILogger? _renderFragmentSerializerLogger; + + private ILogger GetRenderFragmentSerializationLogger() + { + return _renderFragmentSerializerLogger ??= _httpContext.RequestServices + .GetRequiredService() + .CreateLogger(typeof(RenderFragmentSerializer).FullName!); + } + + private CacheBoundaryService? _cacheBoundaryService; + + private CacheBoundaryService GetCacheBoundaryService() + => _cacheBoundaryService ??= _httpContext.RequestServices.GetRequiredService(); + internal static bool IsProgressivelyEnhancedNavigation(HttpRequest request) { // For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 62797ee22b23..f452532a800e 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -65,6 +65,9 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log internal HttpContext? HttpContext => _httpContext; internal NotFoundEventArgs? NotFoundEventArgs { get; private set; } + internal ArrayRange GetRenderTreeFrames(int componentId) + => GetCurrentRenderTreeFrames(componentId); + internal void SetHttpContext(HttpContext httpContext) { if (_httpContext is null) diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs index c30fe7083925..731f162c3327 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; /// A component that describes a location in prerendered output where client-side code /// should insert an interactive component. /// +[CacheBoundaryPolicy] internal class SSRRenderModeBoundary : IComponent { private static readonly ConcurrentDictionary _componentTypeNameHashCache = new(); @@ -36,6 +37,9 @@ internal class SSRRenderModeBoundary : IComponent public IComponentRenderMode RenderMode { get; } + [DynamicallyAccessedMembers(Component)] + internal Type ComponentType => _componentType; + public SSRRenderModeBoundary( HttpContext httpContext, [DynamicallyAccessedMembers(Component)] Type componentType, diff --git a/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs b/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs new file mode 100644 index 000000000000..8d6a036f0e82 --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs @@ -0,0 +1,517 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class CacheBoundaryKeyResolverTest +{ + [Fact] + public void ComputeKey_IsDeterministic() + { + var component = CreateComponent(); + var httpContext = CreateHttpContext(); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); + + Assert.Equal(key1, key2); + } + + [Fact] + public void ComputeKey_DifferentTreePosition_ProducesDifferentKeys() + { + var httpContext = CreateHttpContext(); + var component1 = CreateComponent(treePositionKey: "ParentA.CacheBoundary"); + var component2 = CreateComponent(treePositionKey: "ParentB.CacheBoundary"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component2, httpContext); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_CacheKey_ChangesOutput() + { + var httpContext = CreateHttpContext(); + var component1 = CreateComponent(cacheKey: "v1"); + var component2 = CreateComponent(cacheKey: "v2"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component2, httpContext); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByQuery_DifferentValues_ProducesDifferentKeys() + { + var component = CreateComponent(varyByQuery: "page"); + var ctx1 = CreateHttpContext(queryString: "?page=1"); + var ctx2 = CreateHttpContext(queryString: "?page=2"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByRoute_DifferentValues_ProducesDifferentKeys() + { + var component = CreateComponent(varyByRoute: "id"); + var ctx1 = CreateHttpContext(routeValues: new RouteValueDictionary { ["id"] = "1" }); + var ctx2 = CreateHttpContext(routeValues: new RouteValueDictionary { ["id"] = "2" }); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByHeader_DifferentValues_ProducesDifferentKeys() + { + var component = CreateComponent(varyByHeader: "Accept-Language"); + var ctx1 = CreateHttpContext(headers: new Dictionary { ["Accept-Language"] = "en-US" }); + var ctx2 = CreateHttpContext(headers: new Dictionary { ["Accept-Language"] = "fr-FR" }); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByCookie_DifferentValues_ProducesDifferentKeys() + { + var component = CreateComponent(varyByCookie: "session"); + var ctx1 = CreateHttpContext(cookieHeader: "session=abc"); + var ctx2 = CreateHttpContext(cookieHeader: "session=xyz"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByUser_DifferentUsers_ProducesDifferentKeys() + { + var component = CreateComponent(varyByUser: true); + var ctx1 = CreateHttpContext(userName: "alice"); + var ctx2 = CreateHttpContext(userName: "bob"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByCulture_DifferentCultures_ProducesDifferentKeys() + { + var component = CreateComponent(varyByCulture: true); + var httpContext = CreateHttpContext(); + + var originalCulture = CultureInfo.CurrentCulture; + var originalUICulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); + + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + CultureInfo.CurrentUICulture = new CultureInfo("fr-FR"); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); + + Assert.NotEqual(key1, key2); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUICulture; + } + } + + [Fact] + public void ComputeKey_VaryBy_CustomString_ChangesKey() + { + var httpContext = CreateHttpContext(); + var component1 = CreateComponent(varyBy: "dark-theme"); + var component2 = CreateComponent(varyBy: "light-theme"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component2, httpContext); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_NoVaryBy_SameKeyForDifferentRequests() + { + var component = CreateComponent(); + var ctx1 = CreateHttpContext(queryString: "?page=1"); + var ctx2 = CreateHttpContext(queryString: "?page=2"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.Equal(key1, key2); + } + + [Fact] + public void ComputeKey_MultipleVaryBy_AllContribute() + { + var component = CreateComponent(varyByQuery: "page", varyByHeader: "Accept"); + var ctx1 = CreateHttpContext(queryString: "?page=1", headers: new Dictionary { ["Accept"] = "text/html" }); + var ctx2 = CreateHttpContext(queryString: "?page=1", headers: new Dictionary { ["Accept"] = "application/json" }); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_DifferentVaryByDimensions_DoNotCollide() + { + // A query param named "user" with value "alice" should not collide + // with VaryByUser=true when the username is "alice" + var componentWithQuery = CreateComponent(varyByQuery: "user"); + var ctxWithQueryUser = CreateHttpContext(queryString: "?user=alice"); + + var componentWithUser = CreateComponent(varyByUser: true); + var ctxWithAuthUser = CreateHttpContext(userName: "alice"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(componentWithQuery, ctxWithQueryUser); + var key2 = CacheBoundaryKeyResolver.ComputeKey(componentWithUser, ctxWithAuthUser); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_DifferentCollectionDimensions_DoNotCollide() + { + // A cookie named "lang" with value "en" should not collide + // with a header named "lang" with value "en" + var componentWithCookie = CreateComponent(varyByCookie: "lang"); + var ctxWithCookie = CreateHttpContext(cookieHeader: "lang=en"); + + var componentWithHeader = CreateComponent(varyByHeader: "lang"); + var ctxWithHeader = CreateHttpContext(headers: new Dictionary { ["lang"] = "en" }); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(componentWithCookie, ctxWithCookie); + var key2 = CacheBoundaryKeyResolver.ComputeKey(componentWithHeader, ctxWithHeader); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_DelimiterInjectionInQueryValue_DoesNotCollide() + { + // A single query param "a" with value "x||b||y" must not collide + // with two query params "a" and "b" with values "x" and "y". + var componentSingle = CreateComponent(varyByQuery: "a"); + var ctxSingle = CreateHttpContext(queryString: "?a=x%7C%7Cb%7C%7Cy"); + + var componentMulti = CreateComponent(varyByQuery: "a,b"); + var ctxMulti = CreateHttpContext(queryString: "?a=x&b=y"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(componentSingle, ctxSingle); + var key2 = CacheBoundaryKeyResolver.ComputeKey(componentMulti, ctxMulti); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByUser_AnonymousUser_DiffersFromNoVaryByUser() + { + var componentWithVaryByUser = CreateComponent(varyByUser: true); + var componentWithoutVaryByUser = CreateComponent(varyByUser: false); + var ctx = CreateHttpContext(); // anonymous — no user set + + var key1 = CacheBoundaryKeyResolver.ComputeKey(componentWithVaryByUser, ctx); + var key2 = CacheBoundaryKeyResolver.ComputeKey(componentWithoutVaryByUser, ctx); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByQuery_MissingParam_ProducesSameKeyAsNoParam() + { + var component = CreateComponent(varyByQuery: "missing"); + var ctx1 = CreateHttpContext(queryString: "?other=1"); + var ctx2 = CreateHttpContext(queryString: "?another=2"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.Equal(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByUser_DistinguishesByNameIdentifier_WhenNameIsAbsent() + { + var component = CreateComponent(varyByUser: true); + var ctxA = CreateHttpContext(nameIdentifier: "user-a", authType: "Bearer"); + var ctxB = CreateHttpContext(nameIdentifier: "user-b", authType: "Bearer"); + + var keyA = CacheBoundaryKeyResolver.ComputeKey(component, ctxA); + var keyB = CacheBoundaryKeyResolver.ComputeKey(component, ctxB); + + Assert.NotEqual(keyA, keyB); + } + + [Fact] + public void ComputeKey_VaryByUser_DistinguishesByAuthenticationType() + { + var component = CreateComponent(varyByUser: true); + var ctxCookie = CreateHttpContext(nameIdentifier: "shared-id", authType: "Cookies"); + var ctxBearer = CreateHttpContext(nameIdentifier: "shared-id", authType: "Bearer"); + + var keyCookie = CacheBoundaryKeyResolver.ComputeKey(component, ctxCookie); + var keyBearer = CacheBoundaryKeyResolver.ComputeKey(component, ctxBearer); + + Assert.NotEqual(keyCookie, keyBearer); + } + + [Fact] + public void ComputeKey_VaryByUser_AnonymousDoesNotMatchAuthenticatedWithEmptyName() + { + var component = CreateComponent(varyByUser: true); + var anonymousCtx = CreateHttpContext(); + var emptyNameAuthCtx = CreateHttpContext(userName: "", authType: "test"); + + var keyAnon = CacheBoundaryKeyResolver.ComputeKey(component, anonymousCtx); + var keyAuth = CacheBoundaryKeyResolver.ComputeKey(component, emptyNameAuthCtx); + + Assert.NotEqual(keyAnon, keyAuth); + } + + [Fact] + public void ComputeKey_VaryByQuery_NameOrderDoesNotChangeKey() + { + var componentForward = CreateComponent(varyByQuery: "page,sort"); + var componentReversed = CreateComponent(varyByQuery: "sort,page"); + var ctx = CreateHttpContext(queryString: "?page=1&sort=name"); + + var keyForward = CacheBoundaryKeyResolver.ComputeKey(componentForward, ctx); + var keyReversed = CacheBoundaryKeyResolver.ComputeKey(componentReversed, ctx); + + Assert.Equal(keyForward, keyReversed); + } + + [Fact] + public void ComputeKey_VaryByQueryWildcard_VariesByAnyParameter() + { + var component = CreateComponent(varyByQuery: "*"); + var ctx1 = CreateHttpContext(queryString: "?sort=name"); + var ctx2 = CreateHttpContext(queryString: "?sort=date"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByQueryWildcard_ParameterOrderDoesNotChangeKey() + { + // Whole-query keys are canonicalized by sorting param names, so reordered URLs collide. + var component = CreateComponent(varyByQuery: "*"); + var ctxForward = CreateHttpContext(queryString: "?a=1&b=2"); + var ctxReversed = CreateHttpContext(queryString: "?b=2&a=1"); + + var keyForward = CacheBoundaryKeyResolver.ComputeKey(component, ctxForward); + var keyReversed = CacheBoundaryKeyResolver.ComputeKey(component, ctxReversed); + + Assert.Equal(keyForward, keyReversed); + } + + [Fact] + public void ComputeKey_VaryByQueryWildcard_VariesByParameterNotInNamedSubset() + { + var component = CreateComponent(varyByQuery: "*"); + var ctx1 = CreateHttpContext(queryString: "?page=1&extra=a"); + var ctx2 = CreateHttpContext(queryString: "?page=1&extra=b"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_TreePositionKey_CannotForgeCacheKeySection() + { + var legit = CreateComponent(cacheKey: "secret", treePositionKey: "P"); + var forged = CreateComponent(treePositionKey: "P||CacheKey||\u0000\u0000\u0000\u0006secret"); + var ctx = CreateHttpContext(); + + var keyLegit = CacheBoundaryKeyResolver.ComputeKey(legit, ctx); + var keyForged = CacheBoundaryKeyResolver.ComputeKey(forged, ctx); + + Assert.NotEqual(keyLegit, keyForged); + } + + [Fact] + public void ComputeKey_VaryByQuery_MultiValue_DoesNotCollideWithCommaJoinedValue() + { + var component = CreateComponent(varyByQuery: "a"); + var ctxMulti = CreateHttpContext(queryString: "?a=1&a=2"); + var ctxJoined = CreateHttpContext(queryString: "?a=1%2C2"); + + var keyMulti = CacheBoundaryKeyResolver.ComputeKey(component, ctxMulti); + var keyJoined = CacheBoundaryKeyResolver.ComputeKey(component, ctxJoined); + + Assert.NotEqual(keyMulti, keyJoined); + } + + [Fact] + public void ComputeKey_VaryByQuery_PresentButEmpty_DiffersFromAbsent() + { + var component = CreateComponent(varyByQuery: "a"); + var ctxEmpty = CreateHttpContext(queryString: "?a="); + var ctxAbsent = CreateHttpContext(queryString: "?other=1"); + + var keyEmpty = CacheBoundaryKeyResolver.ComputeKey(component, ctxEmpty); + var keyAbsent = CacheBoundaryKeyResolver.ComputeKey(component, ctxAbsent); + + Assert.NotEqual(keyEmpty, keyAbsent); + } + + [Fact] + public void ComputeKey_VaryByHeader_MultiValue_DoesNotCollideWithCommaJoinedValue() + { + var component = CreateComponent(varyByHeader: "X-Test"); + + var ctxMulti = CreateHttpContext(); + ctxMulti.Request.Headers["X-Test"] = new StringValues(new[] { "1", "2" }); + + var ctxJoined = CreateHttpContext(); + ctxJoined.Request.Headers["X-Test"] = "1,2"; + + var keyMulti = CacheBoundaryKeyResolver.ComputeKey(component, ctxMulti); + var keyJoined = CacheBoundaryKeyResolver.ComputeKey(component, ctxJoined); + + Assert.NotEqual(keyMulti, keyJoined); + } + + [Fact] + public void ComputeKey_VaryByUser_SameNameIdDifferentIssuer_ProducesDifferentKeys() + { + var component = CreateComponent(varyByUser: true); + var ctxA = CreateHttpContext(nameIdentifier: "shared", authType: "Bearer", nameIdentifierIssuer: "https://idp-a"); + var ctxB = CreateHttpContext(nameIdentifier: "shared", authType: "Bearer", nameIdentifierIssuer: "https://idp-b"); + + var keyA = CacheBoundaryKeyResolver.ComputeKey(component, ctxA); + var keyB = CacheBoundaryKeyResolver.ComputeKey(component, ctxB); + + Assert.NotEqual(keyA, keyB); + } + + [Fact] + public void ComputeKey_VaryByUser_EmptyNameId_FallsBackToClaims() + { + var component = CreateComponent(varyByUser: true); + var ctxAlice = CreateHttpContext(userName: "alice", nameIdentifier: ""); + var ctxBob = CreateHttpContext(userName: "bob", nameIdentifier: ""); + + var keyAlice = CacheBoundaryKeyResolver.ComputeKey(component, ctxAlice); + var keyBob = CacheBoundaryKeyResolver.ComputeKey(component, ctxBob); + + Assert.NotEqual(keyAlice, keyBob); + } + + private static RenderFragment DefaultChildContent => builder => builder.AddContent(0, "test"); + + private static CacheBoundary CreateComponent( + RenderFragment childContent = null, + string cacheKey = null, + string varyByQuery = null, + string varyByRoute = null, + string varyByHeader = null, + string varyByCookie = null, + bool? varyByUser = null, + bool? varyByCulture = null, + string varyBy = null, + string treePositionKey = "DefaultParent.CacheBoundary") + { + var component = new CacheBoundary + { + ChildContent = childContent ?? DefaultChildContent, + CacheKey = cacheKey, + VaryByQuery = varyByQuery, + VaryByRoute = varyByRoute, + VaryByHeader = varyByHeader, + VaryByCookie = varyByCookie, + VaryByUser = varyByUser, + VaryByCulture = varyByCulture, + VaryBy = varyBy, + TreePositionKeyFactory = () => treePositionKey, + }; + return component; + } + + private static DefaultHttpContext CreateHttpContext( + string queryString = null, + RouteValueDictionary routeValues = null, + Dictionary headers = null, + string cookieHeader = null, + string userName = null, + string nameIdentifier = null, + string nameIdentifierIssuer = null, + string authType = "test") + { + var httpContext = new DefaultHttpContext(); + + if (queryString is not null) + { + httpContext.Request.QueryString = new QueryString(queryString); + } + + if (routeValues is not null) + { + httpContext.Request.RouteValues = routeValues; + } + + if (headers is not null) + { + foreach (var (key, value) in headers) + { + httpContext.Request.Headers[key] = value; + } + } + + if (cookieHeader is not null) + { + httpContext.Request.Headers["Cookie"] = cookieHeader; + } + + if (userName is not null || nameIdentifier is not null) + { + var claims = new List(); + if (userName is not null) + { + claims.Add(new Claim(ClaimTypes.Name, userName)); + } + if (nameIdentifier is not null) + { + claims.Add(nameIdentifierIssuer is null + ? new Claim(ClaimTypes.NameIdentifier, nameIdentifier) + : new Claim(ClaimTypes.NameIdentifier, nameIdentifier, ClaimValueTypes.String, nameIdentifierIssuer)); + } + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, authType)); + } + + return httpContext; + } +} diff --git a/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs new file mode 100644 index 000000000000..3753e1b7203e --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs @@ -0,0 +1,188 @@ +// 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 Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class CacheBoundaryRenderTest +{ + [Fact] + public async Task DeserializationFailure_FallsBackToChildContent_AndLogsWarning() + { + var testLogger = new TestLogger(); + var httpContext = CreateHttpContext(); + + var store = new TestCacheStore { ReturnForAnyKey = "NOT VALID JSON {{{" }; + var service = new CacheBoundaryService(store, new TestLoggerFactory(testLogger)); + + var component = new CacheBoundary + { + ChildContent = builder => builder.AddContent(0, "fallback"), + CacheService = service, + HttpContext = httpContext, + }; + + var frames = await RenderComponent(component); + + AssertContainsText(frames, "fallback"); + var entry = Assert.Single(testLogger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("Failed to restore CacheBoundary", entry.Message); + Assert.NotNull(entry.Exception); + } + + private static void AssertContainsText(ArrayRange frames, string expectedText) + { + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Text && frame.TextContent == expectedText) + { + return; + } + } + + Assert.Fail($"Expected to find text frame '{expectedText}' but it was not present."); + } + + [Fact] + public async Task CacheHit_DoesNotInvokeChildContent() + { + var httpContext = CreateHttpContext(); + + var precomputed = new SerializedRenderFragment + { + Nodes = [new RenderTreeNode { Type = "markup", Content = "

from-cache

" }], + }; + var store = new TestCacheStore { ReturnForAnyKey = JsonSerializer.Serialize(precomputed, ServerComponentSerializationSettings.JsonSerializationOptions) }; + var service = new CacheBoundaryService(store, new TestLoggerFactory(new TestLogger())); + + var childContentInvocations = 0; + var component = new CacheBoundary + { + ChildContent = builder => + { + childContentInvocations++; + builder.AddMarkupContent(0, "

from-fresh

"); + }, + CacheService = service, + HttpContext = httpContext, + }; + + var frames = await RenderComponent(component); + + Assert.Equal(0, childContentInvocations); + AssertContainsMarkup(frames, "

from-cache

"); + } + + private static void AssertContainsMarkup(ArrayRange frames, string expectedMarkup) + { + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Markup && frame.MarkupContent == expectedMarkup) + { + return; + } + } + + Assert.Fail($"Expected to find markup frame '{expectedMarkup}' but it was not present."); + } + + private sealed class TestCacheStore : ICacheBoundaryStore + { + public Dictionary Data { get; } = new(); + public string ReturnForAnyKey { get; set; } + + public async ValueTask GetOrCreateAsync( + string key, + Func> factory, + CacheStoreOptions options, + CancellationToken cancellationToken) + { + if (ReturnForAnyKey is not null) + { + return ReturnForAnyKey; + } + + if (Data.TryGetValue(key, out var value)) + { + return value; + } + + var created = await factory(cancellationToken).ConfigureAwait(false); + Data[key] = created; + return created; + } + + public void Dispose() { } + } + + private static HttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Get; + context.Request.Scheme = "http"; + context.Request.Host = new HostString("localhost"); + context.Request.Path = "/test"; + context.RequestServices = new TestServiceProviderWithLogger(new TestLogger()); + + return context; + } + + private static async Task> RenderComponent(CacheBoundary component) + { + var renderer = new TestRenderer(); + var id = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(id); + + return renderer.GetCurrentRenderTreeFrames(id); + } + + private sealed class TestLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + + public record LogEntry(LogLevel Level, string Message, Exception Exception); + } + + private sealed class TestServiceProviderWithLogger : IServiceProvider + { + private readonly TestLogger _logger; + + public TestServiceProviderWithLogger(TestLogger logger) + { + _logger = logger; + } + + public object GetService(Type serviceType) + => serviceType == typeof(ILoggerFactory) ? new TestLoggerFactory(_logger) : null; + } + + private sealed class TestLoggerFactory : ILoggerFactory + { + private readonly TestLogger _logger; + + public TestLoggerFactory(TestLogger logger) => _logger = logger; + + public ILogger CreateLogger(string categoryName) => _logger; + + public void AddProvider(ILoggerProvider provider) { } + + public void Dispose() { } + } +} diff --git a/src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs b/src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs new file mode 100644 index 000000000000..d2fe2ff66dbc --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.IO; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class CacheBoundaryTextWriterTest +{ + [Fact] + public void CreateHole_HoleWithRenderFragmentParameter_Throws() + { + var capture = new RenderFragmentCapture(CaptureFramesFor(builder => + { + builder.OpenComponent(7); + builder.AddAttribute(8, "ChildContent", (RenderFragment)(b => b.AddContent(0, "inner"))); + builder.CloseComponent(); + })); + + var writer = new CacheBoundaryTextWriter(new StringWriter(), CacheBoundaryVaryBy.None); + writer.StartCapture(); + + var ex = Assert.Throws(() => + writer.CreateHole(typeof(TestRenderFragmentHole), renderMode: null, capture, NullLogger.Instance)); + Assert.Contains("RenderFragment parameter", ex.Message); + } + + [Fact] + public void CreateHole_HoleWithGenericRenderFragment_Throws() + { + var capture = new RenderFragmentCapture(CaptureFramesFor(builder => + { + builder.OpenComponent(7); + builder.AddAttribute(8, "ItemTemplate", (RenderFragment)(item => b => b.AddContent(0, item))); + builder.CloseComponent(); + })); + + var writer = new CacheBoundaryTextWriter(new StringWriter(), CacheBoundaryVaryBy.None); + writer.StartCapture(); + + var ex = Assert.Throws(() => writer.CreateHole(typeof(TestRenderFragmentHole), renderMode: null, capture, NullLogger.Instance)); + Assert.Contains("RenderFragment", ex.Message); + } + + [Fact] + public void CreateHole_HoleWithoutRenderFragmentParameter_SerializesNode() + { + var capture = new RenderFragmentCapture(CaptureFramesFor(builder => + { + builder.OpenComponent(7); + builder.AddComponentParameter(8, "Title", "hello"); + builder.CloseComponent(); + })); + + var writer = new CacheBoundaryTextWriter(new StringWriter(), CacheBoundaryVaryBy.None); + writer.StartCapture(); + writer.CreateHole(typeof(TestRenderFragmentHole), renderMode: null, capture, NullLogger.Instance); + writer.StopCapture(); + + var json = writer.GetJson(); + Assert.Contains(nameof(TestRenderFragmentHole), json); + Assert.Contains("hello", json); + } + + [Fact] + public void GetJson_InterleavesMarkupAndHolesInRenderOrder() + { + var capture = new RenderFragmentCapture(CaptureFramesFor(builder => + { + builder.OpenComponent(7); + builder.AddComponentParameter(8, "Title", "hole-value"); + builder.CloseComponent(); + })); + + var writer = new CacheBoundaryTextWriter(new StringWriter(), CacheBoundaryVaryBy.None); + writer.StartCapture(); + writer.Write("

before

"); + writer.PauseCapture(); + writer.CreateHole(typeof(TestRenderFragmentHole), renderMode: null, capture, NullLogger.Instance); + writer.StartCapture(); + writer.Write("

after

"); + writer.StopCapture(); + + var json = writer.GetJson(); + var beforeIndex = json.IndexOf("before", StringComparison.Ordinal); + var holeIndex = json.IndexOf("hole-value", StringComparison.Ordinal); + var afterIndex = json.IndexOf("after", StringComparison.Ordinal); + Assert.True(beforeIndex >= 0 && holeIndex > beforeIndex && afterIndex > holeIndex); + } + + private static RenderTreeFrame[] CaptureFramesFor(RenderFragment fragment) + { + using var builder = new RenderTreeBuilder(); + fragment(builder); + var frames = builder.GetFrames(); + var slice = new RenderTreeFrame[frames.Count]; + Array.Copy(frames.Array, 0, slice, 0, frames.Count); + return slice; + } + + [CacheBoundaryPolicy] + private sealed class TestRenderFragmentHole : IComponent + { + [Parameter] public string? Title { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter] public RenderFragment? ItemTemplate { get; set; } + + public void Attach(RenderHandle renderHandle) + { + } + + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } +} diff --git a/src/Components/Endpoints/test/IsHoleComponentTest.cs b/src/Components/Endpoints/test/IsHoleComponentTest.cs new file mode 100644 index 000000000000..6f70b8942926 --- /dev/null +++ b/src/Components/Endpoints/test/IsHoleComponentTest.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class IsHoleComponentTest +{ + [Fact] + public void NoAttribute_IsNotHole() + { + Assert.False(CacheBoundaryService.IsHoleComponent(typeof(ComponentBase), CacheBoundaryVaryBy.None)); + } + + [Fact] + public void Attribute_NoVaryBy_IsUnconditionalHole() + { + Assert.True(CacheBoundaryService.IsHoleComponent(typeof(UnconditionalHole), CacheBoundaryVaryBy.None)); + Assert.True(CacheBoundaryService.IsHoleComponent(typeof(UnconditionalHole), CacheBoundaryVaryBy.User)); + } + + [Fact] + public void Attribute_Throw_ThrowsWhenNotCovered() + { + Assert.Throws(() => + CacheBoundaryService.IsHoleComponent(typeof(ThrowingComponent), CacheBoundaryVaryBy.None)); + } + + [Fact] + public void Attribute_VaryBy_IsHoleWhenNotCovered_SafeWhenCovered() + { + Assert.True(CacheBoundaryService.IsHoleComponent(typeof(ConditionalHole), CacheBoundaryVaryBy.None)); + Assert.False(CacheBoundaryService.IsHoleComponent(typeof(ConditionalHole), CacheBoundaryVaryBy.User)); + } + + [Fact] + public void Attribute_MultipleVaryByFlags_RequiresFullMatch() + { + var partial = CacheBoundaryVaryBy.User; + var full = CacheBoundaryVaryBy.User | CacheBoundaryVaryBy.Query; + + Assert.True(CacheBoundaryService.IsHoleComponent(typeof(MultiDimensionHole), partial)); + Assert.False(CacheBoundaryService.IsHoleComponent(typeof(MultiDimensionHole), full)); + } + + [Fact] + public void Attribute_Inherited_AppliesToSubclass() + { + Assert.Throws(() => + CacheBoundaryService.IsHoleComponent(typeof(DerivedThrowingComponent), CacheBoundaryVaryBy.None)); + } + + [CacheBoundaryPolicy] + private class UnconditionalHole : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) { } + } + + [CacheBoundaryPolicy(Disallow = true)] + private class ThrowingComponent : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) { } + } + + private sealed class DerivedThrowingComponent : ThrowingComponent { } + + [CacheBoundaryPolicy(VaryBy = CacheBoundaryVaryBy.User)] + private class ConditionalHole : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) { } + } + + [CacheBoundaryPolicy(VaryBy = CacheBoundaryVaryBy.User | CacheBoundaryVaryBy.Query)] + private class MultiDimensionHole : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) { } + } +} diff --git a/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs b/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs index ed10f65d2ac0..e113928f54da 100644 --- a/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs +++ b/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -483,6 +484,46 @@ public void Roundtrip_ComponentWithTypedParameters_PreservesTypesAfterJson() Assert.Equal(3.14, frames.Array[2].AttributeValue); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Roundtrip_ComponentRenderMode_PreservesPrerenderFlag(bool prerender) + { + RenderFragment original = builder => + { + builder.OpenComponent(0); + builder.AddComponentRenderMode(new InteractiveWebAssemblyRenderMode(prerender)); + builder.CloseComponent(); + }; + + var serialized = SerializeFragment(original); + + var node = Assert.Single(serialized); + Assert.Equal("InteractiveWebAssembly", node.RenderModeName); + Assert.Equal(prerender, node.Prerender); + + var json = JsonSerializer.Serialize(serialized, _jsonOptions); + var deserialized = JsonSerializer.Deserialize>(json, _jsonOptions)!; + var fragment = RenderFragmentSerializer.Deserialize(deserialized, _jsonOptions, _typeCache); + + using var builder2 = new RenderTreeBuilder(); + fragment(builder2); + var frames = builder2.GetFrames(); + + IComponentRenderMode? capturedMode = null; + for (var i = 0; i < frames.Count; i++) + { + if (frames.Array[i].FrameType == RenderTreeFrameType.ComponentRenderMode) + { + capturedMode = frames.Array[i].ComponentRenderMode; + break; + } + } + + var mode = Assert.IsType(capturedMode); + Assert.Equal(prerender, mode.Prerender); + } + [Fact] public void Serialize_GenericRenderFragment_IsSkipped() { diff --git a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs index 86b54c0e6ea2..72df98f3806f 100644 --- a/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs +++ b/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs @@ -15,6 +15,7 @@ namespace Microsoft.AspNetCore.Components.QuickGrid; /// /// The type of data represented by each row in the grid. [CascadingTypeParameter(nameof(TGridItem))] +[CacheBoundaryPolicy(Disallow = true, VaryBy = CacheBoundaryVaryBy.Query)] public partial class QuickGrid : IAsyncDisposable { /// diff --git a/src/Components/Shared/src/ComponentKeyHelper.cs b/src/Components/Shared/src/ComponentKeyHelper.cs new file mode 100644 index 000000000000..f42e8217d9e4 --- /dev/null +++ b/src/Components/Shared/src/ComponentKeyHelper.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace Microsoft.AspNetCore.Components; + +internal static class ComponentKeyHelper +{ + internal static bool IsSerializableKey(object key) + { + if (key == null) + { + return false; + } + var keyType = key.GetType(); + var result = Type.GetTypeCode(keyType) != TypeCode.Object + || keyType == typeof(Guid) + || keyType == typeof(DateTimeOffset) + || keyType == typeof(DateOnly) + || keyType == typeof(TimeOnly); + + return result; + } + + internal static string? FormatSerializableKey(object? key) + { + if (key is null || !IsSerializableKey(key)) + { + return null; + } + + return key switch + { + IFormattable formattable => formattable.ToString("", CultureInfo.InvariantCulture), + IConvertible convertible => convertible.ToString(CultureInfo.InvariantCulture), + _ => default, + }; + } +} diff --git a/src/Components/Shared/src/RenderFragmentCapture.cs b/src/Components/Shared/src/RenderFragmentCapture.cs index 2d1f6d58fbb3..a43a4d55c840 100644 --- a/src/Components/Shared/src/RenderFragmentCapture.cs +++ b/src/Components/Shared/src/RenderFragmentCapture.cs @@ -20,6 +20,12 @@ public RenderFragmentCapture(RenderFragment original) _original = original; } + public RenderFragmentCapture(RenderTreeFrame[] capturedFrames) + { + _original = static _ => { }; + _capturedFrames = capturedFrames; + } + public IReadOnlyDictionary ChildCaptures => _childCaptures; public void Invoke(RenderTreeBuilder builder) diff --git a/src/Components/Shared/src/RenderFragmentSerializer.cs b/src/Components/Shared/src/RenderFragmentSerializer.cs index 54b053c53b92..23b22a0b2263 100644 --- a/src/Components/Shared/src/RenderFragmentSerializer.cs +++ b/src/Components/Shared/src/RenderFragmentSerializer.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components; @@ -100,6 +101,7 @@ private static void SerializeChildren( Type = "component", TypeName = frame.ComponentType?.FullName, TypeAssembly = frame.ComponentType?.Assembly.GetName().Name, + Sequence = frame.Sequence, }; if (frame.ComponentKey is not null) { @@ -123,6 +125,18 @@ private static void SerializeChildren( position++; } + while (position < subtreeEnd) + { + ref readonly var inner = ref frames[position]; + if (inner.FrameType is RenderTreeFrameType.ComponentRenderMode) + { + node.RenderModeName = GetRenderModeName(inner.ComponentRenderMode); + node.Prerender = GetRenderModePrerender(inner.ComponentRenderMode); + break; + } + position++; + } + position = subtreeEnd; target.Add(node); break; @@ -143,10 +157,6 @@ private static void SerializeChildren( Log.ComponentReferenceCaptureSkipped(logger, ownerComponentType); position++; break; - case RenderTreeFrameType.ComponentRenderMode: - Log.ComponentRenderModeSkipped(logger, ownerComponentType); - position++; - break; case RenderTreeFrameType.NamedEvent: Log.NamedEventSkipped(logger, ownerComponentType); position++; @@ -224,6 +234,31 @@ internal static RenderFragment Deserialize(List nodes, JsonSeria return builder => DeserializeNodes(builder, nodes, jsonOptions, typeCache); } + internal static string? GetRenderModeName(IComponentRenderMode? renderMode) + { + return renderMode switch + { + null => null, + InteractiveServerRenderMode => "InteractiveServer", + InteractiveWebAssemblyRenderMode => "InteractiveWebAssembly", + InteractiveAutoRenderMode => "InteractiveAuto", + _ => throw new InvalidOperationException($"Unsupported render mode type: '{renderMode.GetType().Name}'."), + }; + } + + // Captures the prerender flag so a custom render-mode instantiation + // (e.g. new InteractiveWebAssemblyRenderMode(prerender: false)) round-trips correctly. + internal static bool GetRenderModePrerender(IComponentRenderMode? renderMode) + { + return renderMode switch + { + InteractiveServerRenderMode m => m.Prerender, + InteractiveWebAssemblyRenderMode m => m.Prerender, + InteractiveAutoRenderMode m => m.Prerender, + _ => true, + }; + } + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Component types referenced in serialized RenderFragments are expected to be preserved by the application.")] private static void DeserializeNodes(RenderTreeBuilder builder, List nodes, JsonSerializerOptions? jsonOptions, ComponentParametersTypeCache typeCache) { @@ -260,12 +295,23 @@ private static void DeserializeNodes(RenderTreeBuilder builder, List prerender ? Web.RenderMode.InteractiveServer : new InteractiveServerRenderMode(prerender: false), + "InteractiveWebAssembly" => prerender ? Web.RenderMode.InteractiveWebAssembly : new InteractiveWebAssemblyRenderMode(prerender: false), + "InteractiveAuto" => prerender ? Web.RenderMode.InteractiveAuto : new InteractiveAutoRenderMode(prerender: false), + _ => throw new InvalidOperationException($"Unknown render mode name '{renderModeName}'."), + }); + } builder.CloseComponent(); break; @@ -356,9 +402,6 @@ private static partial class Log [LoggerMessage(3, LogLevel.Warning, "A component @ref capture inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Component references cannot cross render mode boundaries.", EventName = "ComponentReferenceCaptureSkipped")] public static partial void ComponentReferenceCaptureSkipped(ILogger logger, string? ownerComponentType); - [LoggerMessage(4, LogLevel.Warning, "A @rendermode directive inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. The render mode is already determined by the boundary the RenderFragment is crossing.", EventName = "ComponentRenderModeSkipped")] - public static partial void ComponentRenderModeSkipped(ILogger logger, string? ownerComponentType); - [LoggerMessage(5, LogLevel.Warning, "A @formname directive inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Named events are an SSR-only mechanism and cannot cross render mode boundaries.", EventName = "NamedEventSkipped")] public static partial void NamedEventSkipped(ILogger logger, string? ownerComponentType); @@ -405,6 +448,15 @@ internal sealed class RenderTreeNode [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Children { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Sequence { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RenderModeName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Prerender { get; set; } } internal sealed class RenderTreeAttribute diff --git a/src/Components/Web/src/Forms/AntiforgeryToken.cs b/src/Components/Web/src/Forms/AntiforgeryToken.cs index aac81a69ecf3..8262a42ee3ff 100644 --- a/src/Components/Web/src/Forms/AntiforgeryToken.cs +++ b/src/Components/Web/src/Forms/AntiforgeryToken.cs @@ -9,6 +9,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// /// Component that renders an antiforgery token as a hidden field. /// +[CacheBoundaryPolicy] public class AntiforgeryToken : IComponent { private RenderHandle _handle; diff --git a/src/Components/Web/src/Head/HeadOutlet.cs b/src/Components/Web/src/Head/HeadOutlet.cs index 313ec37b550e..9f3b9b21de01 100644 --- a/src/Components/Web/src/Head/HeadOutlet.cs +++ b/src/Components/Web/src/Head/HeadOutlet.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Components.Web; /// /// Renders content provided by components. /// +[CacheBoundaryPolicy] public sealed class HeadOutlet : ComponentBase { private const string GetAndRemoveExistingTitle = "Blazor._internal.PageTitle.getAndRemoveExistingTitle"; diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 1385f3426fe5..1eebc66949aa 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -13,6 +13,7 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization; /// Provides functionality for rendering a virtualized list of items. /// /// The context type for the items being rendered. +[CacheBoundaryPolicy(Disallow = true)] public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, IAsyncDisposable { private VirtualizeJsInterop? _jsInterop; diff --git a/src/Components/test/E2ETest/Tests/CacheBoundaryHybridCacheTest.cs b/src/Components/test/E2ETest/Tests/CacheBoundaryHybridCacheTest.cs new file mode 100644 index 000000000000..197acfc50b59 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/CacheBoundaryHybridCacheTest.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class CacheBoundaryHybridCacheTest : CacheBoundaryTestBase +{ + public CacheBoundaryHybridCacheTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void ConfigureServerArguments() + => _serverFixture.AdditionalArguments.Add("--UseHybridCacheBoundaryStore=true"); +} diff --git a/src/Components/test/E2ETest/Tests/CacheBoundaryMemoryCacheTest.cs b/src/Components/test/E2ETest/Tests/CacheBoundaryMemoryCacheTest.cs new file mode 100644 index 000000000000..39f6ac0501ea --- /dev/null +++ b/src/Components/test/E2ETest/Tests/CacheBoundaryMemoryCacheTest.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class CacheBoundaryMemoryCacheTest : CacheBoundaryTestBase +{ + public CacheBoundaryMemoryCacheTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } +} diff --git a/src/Components/test/E2ETest/Tests/CacheBoundaryTestBase.cs b/src/Components/test/E2ETest/Tests/CacheBoundaryTestBase.cs new file mode 100644 index 000000000000..565aeb5fb75f --- /dev/null +++ b/src/Components/test/E2ETest/Tests/CacheBoundaryTestBase.cs @@ -0,0 +1,216 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public abstract class CacheBoundaryTestBase : ServerTestBase>> +{ + // A unique id per test instance. Every CacheBoundary on the test pages varies by the "testId" query + // parameter, so each test's cache entries are isolated. This lets the suite run without relying on a + // shared, globally-cleared cache and keeps tests independent when executed concurrently. + private readonly string _testId = Guid.NewGuid().ToString("N"); + + protected CacheBoundaryTestBase( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + // The streaming context uses PageLoadStrategy.None so navigation does not block on the load event while + // content is still streaming in. This is required by the tests that assert on streaming-rendered content. + public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); + + protected override void InitializeAsyncCore() + { + ConfigureServerArguments(); + base.InitializeAsyncCore(); + } + + // Hook for derived classes to select the cache store backing the server (e.g. HybridCache). + protected virtual void ConfigureServerArguments() + { + } + + // Builds a URL for the given page path, appending this test's unique testId so cache entries and the + // render counter are isolated per test. + private string TestUrl(string path) + { + var separator = path.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + return $"{ServerPathBase}/{path}{separator}testId={_testId}"; + } + + [Fact] + public void CacheBoundaryCachesData() + { + Navigate(TestUrl("cache-component")); + var testElement = Browser.FindElement(By.Id("test-1")); + var cachedValue = testElement.FindElement(By.CssSelector(".cached")).Text; + + Navigate(TestUrl("cache-component")); + Browser.Equal(cachedValue, () => Browser.FindElement(By.Id("test-1")).FindElement(By.CssSelector(".cached")).Text); + Browser.NotEqual(cachedValue, () => Browser.FindElement(By.Id("test-1")).FindElement(By.CssSelector(".not-cached")).Text); + Browser.NotEqual(cachedValue, () => Browser.FindElement(By.Id("test-1")).FindElement(By.CssSelector(".not-cache-component")).Text); + } + + [Fact] + public void CacheBoundaryDoesNotCacheDataWhenNotEnabled() + { + Navigate(TestUrl("cache-component")); + var testElement = Browser.FindElement(By.Id("test-2")); + var firstValue = testElement.FindElement(By.CssSelector(".cached")).Text; + + Navigate(TestUrl("cache-component")); + Browser.NotEqual(firstValue, () => Browser.FindElement(By.Id("test-2")).FindElement(By.CssSelector(".cached")).Text); + Browser.NotEqual(firstValue, () => Browser.FindElement(By.Id("test-2")).FindElement(By.CssSelector(".not-cached")).Text); + Browser.NotEqual(firstValue, () => Browser.FindElement(By.Id("test-2")).FindElement(By.CssSelector(".not-cache-component")).Text); + } + + [Fact] + public void CacheBoundaryCorrectlyCreatesHoles() + { + Navigate(TestUrl("cache-component")); + var testElement = Browser.FindElement(By.Id("test-3")); + Browser.Equal("never", () => testElement.FindElement(By.Id("message")).Text); + testElement.FindElement(By.Id("message-input")).SendKeys("new message"); + testElement.FindElement(By.Id("submit")).Click(); + + Browser.Equal("new message", () => Browser.FindElement(By.Id("test-3")).FindElement(By.Id("message")).Text); + testElement = Browser.FindElement(By.Id("test-3")); + testElement.FindElement(By.Id("message-input")).SendKeys("cache hit"); + testElement.FindElement(By.Id("submit")).Click(); + + Browser.Equal("cache hit", () => Browser.FindElement(By.Id("test-3")).FindElement(By.Id("message")).Text); + } + + [Fact] + public void EditFormWithFormComponents_CachesStaticContent_AndFormStillSubmits() + { + Navigate(TestUrl("cache-component-form")); + var cachedGuid = Browser.FindElement(By.Id("test-form-in-cache")).FindElement(By.CssSelector(".form-cached-guid")).Text; + // The DisplayName form component rendered inside the cache. + Browser.Equal("Message", () => Browser.FindElement(By.Id("test-form-in-cache")).FindElement(By.CssSelector(".form-display-name")).Text); + Browser.Equal("never", () => Browser.FindElement(By.Id("test-form-in-cache")).FindElement(By.Id("cached-form-message")).Text); + + // Warm reload: the cached form content (static guid + form components) is served from the cache. + Navigate(TestUrl("cache-component-form")); + Browser.Equal(cachedGuid, () => Browser.FindElement(By.Id("test-form-in-cache")).FindElement(By.CssSelector(".form-cached-guid")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("test-form-in-cache")).FindElement(By.CssSelector(".form-display-name")).Text); + + // The form still submits: the POST renders live and dispatches to OnValidSubmit. + var form = Browser.FindElement(By.Id("test-form-in-cache")); + form.FindElement(By.Id("cached-form-input")).SendKeys("hello"); + form.FindElement(By.Id("cached-form-submit")).Click(); + Browser.Equal("hello", () => Browser.FindElement(By.Id("test-form-in-cache")).FindElement(By.Id("cached-form-message")).Text); + } + + [Fact] + public void CacheBoundaryInLoopUsesVaryByForDistinctEntries() + { + Navigate(TestUrl("cache-component")); + var loopItems = Browser.FindElement(By.Id("test-4")).FindElements(By.CssSelector(".loop-item")); + Assert.Equal(3, loopItems.Count); + + // Each iteration should have its own distinct cached value + var firstRenderValues = new string[3]; + for (var i = 0; i < 3; i++) + { + firstRenderValues[i] = loopItems[i].FindElement(By.CssSelector(".cached-value")).Text; + } + Assert.Equal(3, firstRenderValues.Distinct().Count()); + + // Second navigation — each entry should be independently cached + Navigate(TestUrl("cache-component")); + for (var i = 0; i < 3; i++) + { + var index = i; + Browser.Equal(firstRenderValues[index], () => + Browser.FindElement(By.Id("test-4")) + .FindElements(By.CssSelector(".loop-item"))[index] + .FindElement(By.CssSelector(".cached-value")).Text); + } + } + + [Fact] + public void CacheBoundaryMultipleHolesOfSameType_PreserveCorrectOrder() + { + Navigate(TestUrl("cache-component")); + Browser.Equal("first", () => Browser.FindElement(By.Id("test-5")).FindElement(By.CssSelector(".hole-0")).Text); + Browser.Equal("second", () => Browser.FindElement(By.Id("test-5")).FindElement(By.CssSelector(".hole-1")).Text); + var cachedContent = Browser.FindElement(By.Id("test-5")).FindElement(By.CssSelector(".cached-content")).Text; + + // Cache hit — holes with same (TypeName, Sequence) must not be swapped + Navigate(TestUrl("cache-component")); + Browser.Equal(cachedContent, () => Browser.FindElement(By.Id("test-5")).FindElement(By.CssSelector(".cached-content")).Text); + Browser.Equal("first", () => Browser.FindElement(By.Id("test-5")).FindElement(By.CssSelector(".hole-0")).Text); + Browser.Equal("second", () => Browser.FindElement(By.Id("test-5")).FindElement(By.CssSelector(".hole-1")).Text); + } + + [Fact] + public void ReusableComponentWithCacheBoundary_UsedTwice_SharesOneCacheEntry() + { + Navigate(TestUrl("cache-component")); + Browser.Exists(By.Id("test-6")); + Browser.Equal(2, () => Browser.FindElement(By.Id("test-6")).FindElements(By.CssSelector(".panel-content")).Count); + + // Cold render: each instance rendered its own content. + Assert.Equal(new[] { "alpha", "beta" }, GetPanelTexts(".panel-content")); + var creatorGuid = GetPanelTexts(".panel-guid")[0]; + + // Warm reload: both boundaries share the one cached entry, so both show the creator's cached + // content and the identical cached guid. + Navigate(TestUrl("cache-component")); + Browser.Exists(By.Id("test-6")); + Browser.Equal(2, () => Browser.FindElement(By.Id("test-6")).FindElements(By.CssSelector(".panel-content")).Count); + + Browser.Equal(new[] { "alpha", "alpha" }, () => GetPanelTexts(".panel-content").ToArray()); + Assert.Equal(new[] { creatorGuid, creatorGuid }, GetPanelTexts(".panel-guid")); + } + + [Fact] + public void CacheBoundaryCachesHardcodedHole() + { + Navigate(TestUrl("cache-component")); + var panel = Browser.FindElement(By.Id("test-7")); + var staticGuid = panel.FindElement(By.CssSelector(".panel-static")).Text; + var holeGuid = panel.FindElement(By.CssSelector(".hardcoded-hole")).Text; + Assert.NotEqual(staticGuid, holeGuid); + + // Warm reload: the wrapper's static output (emitted around the hardcoded hole) is served from + // the cache, while the hole itself re-renders fresh on every request. + Navigate(TestUrl("cache-component")); + Browser.Equal(staticGuid, () => Browser.FindElement(By.Id("test-7")).FindElement(By.CssSelector(".panel-static")).Text); + Browser.NotEqual(holeGuid, () => Browser.FindElement(By.Id("test-7")).FindElement(By.CssSelector(".hardcoded-hole")).Text); + } + + [Fact] + public void CacheBoundaryTreatsStreamingChildAsHole() + { + Navigate(TestUrl("cache-component")); + // The streaming component is rendered via a streaming batch, so wait for it to arrive. + var streamingGuid = Browser.Exists(By.CssSelector("#test-8 .streaming-hole")).Text; + var staticGuid = Browser.FindElement(By.Id("test-8")).FindElement(By.CssSelector(".cached-static")).Text; + Assert.NotEqual(staticGuid, streamingGuid); + + // Warm reload: the static content around the streaming component is served from the cache, while + // the streaming component is treated as a hole and re-renders fresh on every request. + Navigate(TestUrl("cache-component")); + Browser.Equal(staticGuid, () => Browser.FindElement(By.Id("test-8")).FindElement(By.CssSelector(".cached-static")).Text); + Browser.NotEqual(streamingGuid, () => Browser.FindElement(By.Id("test-8")).FindElement(By.CssSelector(".streaming-hole")).Text); + } + + private List GetPanelTexts(string selector) + => Browser.FindElement(By.Id("test-6")) + .FindElements(By.CssSelector(selector)) + .Select(panel => panel.Text) + .ToList(); +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index b66823b6f495..9d3f6ae4b6e3 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -35,6 +35,13 @@ public void ConfigureServices(IServiceCollection services) options.MaxFormMappingRecursionDepth = 5; options.MaxFormMappingCollectionSize = 100; }); + + if (Configuration.GetValue("UseHybridCacheBoundaryStore")) + { + services.AddHybridCache(); + builder.AddHybridCacheBoundaryStore(); + } + services.AddHttpContextAccessor(); services.AddCascadingAuthenticationState(); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index a50015fce147..af49ba7635b5 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -11,6 +11,7 @@ using Components.TestServer.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Server; diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor new file mode 100644 index 000000000000..d577ac85697a --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor @@ -0,0 +1,78 @@ +@page "/cache-component" +@page "/cache-component/vary" +@using Microsoft.AspNetCore.Components.Forms + +

CacheBoundary

+ +
+ +

@Guid.NewGuid()

+ +
+

@Guid.NewGuid()

+
+ +
+ +

@Guid.NewGuid()

+ +
+

@Guid.NewGuid()

+
+ +
+ + + + + + + +
+ +
+ @for (var i = 0; i < 3; i++) + { +
+ +

@Guid.NewGuid()

+
+
+ } +
+ +
+ +

@Guid.NewGuid()

+ @for (var i = 0; i < 2; i++) + { + var index = i; + var label = index == 0 ? "first" : "second"; + + } +
+
+ +
+ + +
+ +
+ + + +
+ +
+ +

@Guid.NewGuid()

+ +
+
+ +@code +{ + [SupplyParameterFromForm(FormName = "editFormTest")] + private string Message { get; set; } = "never"; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CachedFormPage.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CachedFormPage.razor new file mode 100644 index 000000000000..a3a150be623f --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CachedFormPage.razor @@ -0,0 +1,35 @@ +@page "/cache-component-form" +@using Microsoft.AspNetCore.Components.Forms + +

CacheBoundary form

+ +
+ + +

@Guid.NewGuid()

+

+
+ +
+

@SubmittedMessage

+
+ +@code +{ + private string SubmittedMessage { get; set; } = "never"; + + public void DisplaySubmitted() => SubmittedMessage = Model.Message; + + [SupplyParameterFromForm] public FormModel Model { get; set; } + + protected override void OnInitialized() => Model ??= new FormModel(); + + public class FormModel + { + public string Message { get; set; } = ""; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/HardcodedHolePanel.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/HardcodedHolePanel.razor new file mode 100644 index 000000000000..8354421bc254 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/HardcodedHolePanel.razor @@ -0,0 +1,4 @@ +
+

@Guid.NewGuid()

+ +
diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/HoleComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/HoleComponent.razor new file mode 100644 index 000000000000..0f66a6e2cab7 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/HoleComponent.razor @@ -0,0 +1,11 @@ +@attribute [CacheBoundaryPolicy] + +

@(Content ?? Guid.NewGuid().ToString())

+ +@code { + [Parameter] public string? Id { get; set; } + + [Parameter] public string CssClass { get; set; } = ""; + + [Parameter] public string? Content { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/ReusablePanel.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/ReusablePanel.razor new file mode 100644 index 000000000000..10670f728508 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/ReusablePanel.razor @@ -0,0 +1,16 @@ +@* A reusable component that internally contains a CacheBoundary with no explicit CacheKey. + Using it more than once on a page produces two CacheBoundary instances that resolve to the + same cache key (same parent type + sequence), with no user error. This must not hang the + request. *@ + +
+ +

@Label

+

@Guid.NewGuid()

+
+
+ +@code { + [Parameter] + public string Label { get; set; } = ""; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/StreamingHoleComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/StreamingHoleComponent.razor new file mode 100644 index 000000000000..6f6271bb3656 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/StreamingHoleComponent.razor @@ -0,0 +1,18 @@ +@attribute [StreamRendering] + +@if (_loaded) +{ +

@Guid.NewGuid()

+} + +@code { + [Parameter] public string CssClass { get; set; } = ""; + + private bool _loaded; + + protected override async Task OnInitializedAsync() + { + await Task.Yield(); + _loaded = true; + } +}