diff --git a/src/Components/Authorization/src/AuthorizeViewCore.cs b/src/Components/Authorization/src/AuthorizeViewCore.cs index e63eae4f11a0..6509d5db4d34 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(Excluded = 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..b87432d76d6e --- /dev/null +++ b/src/Components/Components/src/CacheBoundaryPolicyAttribute.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. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Specifies how a component interacts with an enclosing CacheBoundary. +/// When is , the component's subtree becomes +/// a "hole" in the cached output and is re-rendered on every request. +/// Optionally, set to lift the exclusion when the cache boundary +/// varies by the specified dimensions. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public sealed class CacheBoundaryPolicyAttribute : Attribute +{ + /// + /// Gets or sets a value indicating whether the component should be excluded + /// from cached output. Defaults to . + /// + public bool Excluded { get; set; } + + /// + /// Gets or sets the vary-by dimensions that, when active on the enclosing + /// CacheBoundary, lift the exclusion. The component is only excluded when + /// the cache boundary does not vary by all specified dimensions. + /// 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 89f27de279af..c4d5c280d8cc 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 c243d71cf252..6ceb3fb2328c 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.Excluded.get -> bool +Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.Excluded.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/Components/src/Sections/SectionContent.cs b/src/Components/Components/src/Sections/SectionContent.cs index c2d402ff6d97..fd4e12979021 100644 --- a/src/Components/Components/src/Sections/SectionContent.cs +++ b/src/Components/Components/src/Sections/SectionContent.cs @@ -6,6 +6,7 @@ namespace Microsoft.AspNetCore.Components.Sections; /// /// Provides content to components with matching s. /// +[CacheBoundaryPolicy(Excluded = true)] public sealed class SectionContent : IComponent, IDisposable { private object? _registeredIdentifier; diff --git a/src/Components/Components/src/Sections/SectionOutlet.cs b/src/Components/Components/src/Sections/SectionOutlet.cs index 89b014d907d7..60a9557a668d 100644 --- a/src/Components/Components/src/Sections/SectionOutlet.cs +++ b/src/Components/Components/src/Sections/SectionOutlet.cs @@ -8,6 +8,7 @@ namespace Microsoft.AspNetCore.Components.Sections; /// /// Renders content provided by components with matching s. /// +[CacheBoundaryPolicy(Excluded = true)] public sealed class SectionOutlet : IComponent, IDisposable { private static readonly RenderFragment _emptyRenderFragment = _ => { }; diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs new file mode 100644 index 000000000000..bdbff762bac7 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs @@ -0,0 +1,260 @@ +// 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.Components.RenderTree; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +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 : ComponentBase +{ + /// + /// 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. + /// + [Parameter] + public TimeSpan? ExpiresSliding { get; set; } + + /// + /// Gets or sets the policy for the cache entry. + /// + [Parameter] + public CacheItemPriority? Priority { get; set; } + + /// + /// Gets or sets a comma-separated list of query string parameter names to vary the cache by. + /// + [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 ICacheBoundaryStore? CacheStore { get; set; } + + [CascadingParameter] internal HttpContext? HttpContext { get; set; } + + internal Func? TreePositionKeyFactory { get; set; } + + internal string? TreePositionKey => TreePositionKeyFactory?.Invoke(); + + internal string? ResolvedCacheKey { get; private set; } + internal string? CachedData { get; private set; } + + internal CacheBoundaryVaryBy GetVaryByOptions() + { + var result = CacheBoundaryVaryBy.None; + + if (VaryByQuery is not null) + { + result |= CacheBoundaryVaryBy.Query; + } + + if (VaryByRoute is not null) + { + result |= CacheBoundaryVaryBy.Route; + } + + if (VaryByHeader is not null) + { + result |= CacheBoundaryVaryBy.Header; + } + + if (VaryByCookie is not null) + { + result |= CacheBoundaryVaryBy.Cookie; + } + + if (VaryByUser is true) + { + result |= CacheBoundaryVaryBy.User; + } + + if (VaryByCulture is true) + { + result |= CacheBoundaryVaryBy.Culture; + } + + return result; + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Enabled && CacheStore is not null && HttpContext is { } httpContext) + { + ResolvedCacheKey = CacheBoundaryKeyResolver.ComputeKey(this, httpContext); + CachedData = CacheStore.Get(ResolvedCacheKey); + } + + if (!TryRestoreFromCache(out var cacheJson)) + { + CachedData = null; + builder.AddContent(0, ChildContent); + return; + } + + using var scratchBuilder = new RenderTreeBuilder(); + ArrayRange freshFrames = default; + if (ChildContent is not null) + { + ChildContent(scratchBuilder); + freshFrames = scratchBuilder.GetFrames(); + } + + int seq = 0; + int freshFrameSearchStart = 0; + foreach (var segment in cacheJson!) + { + switch (segment.Kind) + { + case CacheSegmentKind.Html: + builder.AddMarkupContent(seq++, segment.Html); + break; + + case CacheSegmentKind.Hole: + builder.OpenComponent(seq++, segment.ComponentType!); + if (segment.ComponentKey is not null) + { + builder.SetKey(segment.ComponentKey); + } + freshFrameSearchStart = ApplyFreshAttributes( + builder, ref seq, freshFrames, freshFrameSearchStart, segment.ComponentType!); + if (segment.RenderModeName is { } renderModeName) + { + builder.AddComponentRenderMode(renderModeName switch + { + "InteractiveServer" => Web.RenderMode.InteractiveServer, + "InteractiveWebAssembly" => Web.RenderMode.InteractiveWebAssembly, + "InteractiveAuto" => Web.RenderMode.InteractiveAuto, + _ => throw new InvalidOperationException($"Unknown cached render mode: '{renderModeName}'."), + }); + } + builder.CloseComponent(); + break; + } + } + } + + private static int ApplyFreshAttributes( + RenderTreeBuilder builder, ref int seq, + ArrayRange freshFrames, int searchStart, Type componentType) + { + for (var i = searchStart; i < freshFrames.Count; i++) + { + ref var frame = ref freshFrames.Array[i]; + if (frame.FrameType != RenderTreeFrameType.Component || frame.ComponentType != componentType) + { + continue; + } + + for (var j = i + 1; j < freshFrames.Count; j++) + { + ref var attrFrame = ref freshFrames.Array[j]; + if (attrFrame.FrameType != RenderTreeFrameType.Attribute) + { + break; + } + builder.AddComponentParameter(seq++, attrFrame.AttributeName, attrFrame.AttributeValue); + } + return i + 1; + } + return searchStart; + } + + private bool TryRestoreFromCache(out CacheBoundaryJson? cacheJson) + { + cacheJson = null; + + if (string.IsNullOrEmpty(CachedData)) + { + return false; + } + + try + { + cacheJson = CacheBoundaryJson.Deserialize(CachedData); + return cacheJson.Count > 0; + } + catch (Exception ex) + { + HttpContext?.RequestServices.GetService() + ?.CreateLogger() + .LogWarning(ex, "Failed to restore CacheBoundary from cached data. Falling back to fresh render."); + return false; + } + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs new file mode 100644 index 000000000000..3b3614d4914e --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs @@ -0,0 +1,161 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components.Web; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed partial class CacheBoundaryJson +{ + private readonly List _segments = []; + + public int Count => _segments.Count; + + public void AddHtml(string html) + { + ArgumentNullException.ThrowIfNull(html); + _segments.Add(CacheSegment.CreateHtml(html)); + } + + public void AddHole(Type componentType, string? renderModeName = null, object? componentKey = null) + { + ArgumentNullException.ThrowIfNull(componentType); + _segments.Add(CacheSegment.CreateHole(componentType, renderModeName, componentKey)); + } + + public List.Enumerator GetEnumerator() => _segments.GetEnumerator(); + + public string Serialize() + { + var entries = new JsonCacheSegment[_segments.Count]; + for (var i = 0; i < _segments.Count; i++) + { + var segment = _segments[i]; + entries[i] = segment.Kind switch + { + CacheSegmentKind.Html => new JsonCacheSegment { Type = "html", Content = segment.Html }, + CacheSegmentKind.Hole => new JsonCacheSegment + { + Type = "hole", + Content = segment.ComponentType!.AssemblyQualifiedName, + RenderMode = segment.RenderModeName, + Key = SerializeKey(segment.ComponentKey), + KeyType = segment.ComponentKey?.GetType().FullName, + }, + _ => throw new InvalidOperationException($"Unknown segment kind: {segment.Kind}"), + }; + } + return JsonSerializer.Serialize(entries, CacheJsonContext.Default.JsonCacheSegmentArray); + } + + public static CacheBoundaryJson Deserialize(string json) + { + ArgumentNullException.ThrowIfNull(json); + + var entries = JsonSerializer.Deserialize(json, CacheJsonContext.Default.JsonCacheSegmentArray) + ?? throw new InvalidOperationException("Failed to deserialize cache entry."); + + var result = new CacheBoundaryJson(); + foreach (var entry in entries) + { + switch (entry.Type) + { + case "html": + result.AddHtml(entry.Content ?? string.Empty); + break; + case "hole": + var type = Type.GetType(entry.Content ?? throw new InvalidOperationException("Hole segment missing component type.")) + ?? throw new InvalidOperationException($"Could not resolve hole component type: '{entry.Content}'."); + if (!typeof(IComponent).IsAssignableFrom(type)) + { + throw new InvalidOperationException($"Resolved type '{type.FullName}' is not a valid component type."); + } + result.AddHole(type, entry.RenderMode, DeserializeKey(entry.Key, entry.KeyType)); + break; + default: + throw new InvalidOperationException($"Unknown cache segment type: '{entry.Type}'."); + } + } + + return result; + } + + private static string? SerializeKey(object? key) + { + if (key is null) + { + return null; + } + return JsonSerializer.Serialize(key); + } + + private static object? DeserializeKey(string? keyValue, string? keyType) + { + if (keyValue is null || keyType is null) + { + return null; + } + var type = Type.GetType(keyType) ?? throw new InvalidOperationException($"Could not resolve key type: '{keyType}'."); + return JsonSerializer.Deserialize(keyValue, type); + } + + internal sealed class JsonCacheSegment + { + public string Type { get; set; } = "html"; + + public string? Content { get; set; } + + public string? RenderMode { get; set; } + + public string? Key { get; set; } + + public string? KeyType { get; set; } + } + + [JsonSerializable(typeof(JsonCacheSegment[]))] + internal sealed partial class CacheJsonContext : JsonSerializerContext + { + } +} + +internal readonly struct CacheSegment +{ + public CacheSegmentKind Kind { get; } + public string? Html { get; } + public Type? ComponentType { get; } + public string? RenderModeName { get; } + public object? ComponentKey { get; } + + private CacheSegment(CacheSegmentKind kind, string? html, Type? componentType, string? renderModeName = null, object? componentKey = null) + { + Kind = kind; + Html = html; + ComponentType = componentType; + RenderModeName = renderModeName; + ComponentKey = componentKey; + } + + public static CacheSegment CreateHtml(string html) => new(CacheSegmentKind.Html, html, componentType: null); + public static CacheSegment CreateHole(Type componentType, string? renderModeName = null, object? componentKey = null) + => new(CacheSegmentKind.Hole, html: null, componentType, renderModeName, componentKey); + + 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}'."), + }; + } +} + +internal enum CacheSegmentKind +{ + Html, + Hole, +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs new file mode 100644 index 000000000000..feaeda99dfd9 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal static class CacheBoundaryKeyResolver +{ + private static readonly char[] _separator = [',']; + + internal static string ComputeKey(CacheBoundary cacheBoundary, HttpContext httpContext) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendString(hash, cacheBoundary.TreePositionKey); + + if (cacheBoundary.CacheKey is not null) + { + AppendString(hash, "||CacheKey||"); + AppendString(hash, cacheBoundary.CacheKey); + } + + var request = httpContext.Request; + if (cacheBoundary.VaryBy is { } varyBy) + { + AppendString(hash, "||VaryBy||"); + AppendString(hash, varyBy); + } + + if (cacheBoundary.VaryByQuery is not null) + { + AppendDelimitedValues(hash, "VaryByQuery", cacheBoundary.VaryByQuery, name => (string?)request.Query[name]); + } + + if (cacheBoundary.VaryByRoute is not null) + { + AppendDelimitedValues(hash, "VaryByRoute", cacheBoundary.VaryByRoute, name => request.RouteValues[name]?.ToString()); + } + + if (cacheBoundary.VaryByHeader is not null) + { + AppendDelimitedValues(hash, "VaryByHeader", cacheBoundary.VaryByHeader, name => (string?)request.Headers[name]); + } + + if (cacheBoundary.VaryByCookie is not null) + { + AppendDelimitedValues(hash, "VaryByCookie", cacheBoundary.VaryByCookie, name => request.Cookies[name]); + } + + if (cacheBoundary.VaryByUser is true) + { + AppendString(hash, "||VaryByUser||"); + AppendString(hash, httpContext.User.Identity?.Name); + } + + if (cacheBoundary.VaryByCulture is true) + { + AppendString(hash, "||VaryByCulture||"); + AppendString(hash, CultureInfo.CurrentCulture.Name); + AppendString(hash, "||"); + AppendString(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 AppendDelimitedValues( + IncrementalHash hash, + string collectionName, string separatedValues, Func valueAccessor) + { + var names = separatedValues.Split(_separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (names.Length == 0) + { + return; + } + + AppendString(hash, "||"); + AppendString(hash, collectionName); + AppendString(hash, "("); + + for (var i = 0; i < names.Length; i++) + { + if (string.IsNullOrEmpty(valueAccessor(names[i]))) + { + continue; + } + AppendString(hash, "||"); + AppendString(hash, names[i]); + AppendString(hash, "||"); + AppendString(hash, valueAccessor(names[i])); + } + + AppendString(hash, ")"); + } + + private static void AppendString(IncrementalHash hash, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + hash.AppendData(Encoding.UTF8.GetBytes(value)); + } + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs new file mode 100644 index 000000000000..74b6226c679d --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.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. + +namespace Microsoft.AspNetCore.Components.Endpoints; + +/// +/// Provides a store for caching rendered component output as a JSON template-with-holes representation. +/// +internal interface ICacheBoundaryStore : IDisposable +{ + /// + /// Gets a cached JSON template for the specified key, or null on cache miss. + /// + string? Get(string key); + + /// + /// Stores a JSON template for the specified key. + /// + void Set(string key, string json, CacheStoreOptions options = default); + + /// + /// Removes all cached entries. Used primarily for testing scenarios. + /// + void Clear() { } +} 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/MemoryCacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs new file mode 100644 index 000000000000..0eb476169554 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs @@ -0,0 +1,65 @@ +// 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; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed class MemoryCacheBoundaryStore : ICacheBoundaryStore +{ + private readonly MemoryCache _cache; + + public MemoryCacheBoundaryStore(IOptions options) + { + _cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = options.Value.CacheBoundarySizeLimit, + }); + } + + public string? Get(string key) + { + _cache.TryGetValue(key, out string? cached); + return cached; + } + + public void Set(string key, string json, CacheStoreOptions options = default) + { + 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); + } + + public void Clear() + { + _cache.Clear(); + } + + public void Dispose() + { + _cache.Dispose(); + } +} diff --git a/src/Components/Endpoints/src/CacheBoundary/NotCacheBoundary.cs b/src/Components/Endpoints/src/CacheBoundary/NotCacheBoundary.cs new file mode 100644 index 000000000000..422d5244f9d0 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/NotCacheBoundary.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. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components; + +/// +/// A marker component that signals the renderer to not cache the rendered HTML output +/// of its child content. This is useful for opt-out scenarios when a parent component +/// enables caching but certain child content should be excluded. +/// +[CacheBoundaryPolicy(Excluded = true)] +public sealed class NotCacheBoundary : ComponentBase +{ + /// + /// Gets or sets the content not to be cached. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, ChildContent); + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index ea24ebd37da3..68a0d567c794 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -74,6 +74,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); services.AddTempData(); + 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..2f2cff13eecc 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 7a89009f4ecf..39c561d9b66e 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 @@ + diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index e26fbfcce577..45fdf8e9a8a3 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,6 +1,42 @@ #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.NotCacheBoundary +Microsoft.AspNetCore.Components.NotCacheBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.NotCacheBoundary.ChildContent.set -> void +Microsoft.AspNetCore.Components.NotCacheBoundary.NotCacheBoundary() -> void 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..9d102370d56c --- /dev/null +++ b/src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs @@ -0,0 +1,76 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed class CacheBoundaryTextWriter : TextWriter +{ + private readonly TextWriter _innerWriter; + private readonly CacheBoundaryJson _segments = new(); + private readonly StringBuilder _buffer = new(); + private bool _capturing; + + public CacheBoundaryTextWriter(TextWriter inner, CacheBoundaryVaryBy varyBy) + { + _innerWriter = inner; + VaryBy = varyBy; + } + + public CacheBoundaryVaryBy VaryBy { get; set; } + + public bool IsCapturing => _capturing; + + public override Encoding Encoding => _innerWriter.Encoding; + + public override void Write(char value) + { + _innerWriter.Write(value); + if (_capturing) + { + _buffer.Append(value); + } + } + + public override void Write(string? value) + { + _innerWriter.Write(value); + if (_capturing) + { + _buffer.Append(value); + } + } + + public void PauseCapture() + { + if (_buffer.Length > 0) + { + _segments.AddHtml(_buffer.ToString()); + _buffer.Clear(); + } + _capturing = false; + } + + public void StartCapture() + { + _capturing = true; + } + + public void CreateHole(Type componentType, string? renderModeName = null, object? componentKey = null) + { + _segments.AddHole(componentType, renderModeName, componentKey); + } + + public CacheBoundaryJson StopCapture() + { + _capturing = false; + + if (_buffer.Length > 0) + { + _segments.AddHtml(_buffer.ToString()); + _buffer.Clear(); + } + return _segments; + } +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index f76bdc40a236..cab093814922 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,18 @@ 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) + { + 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 +83,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 caclulation, because otherwise multiple CacheBoundary components under the same parent would have + // the same key and will 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 2890acec7005..faee42b697c4 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -1,7 +1,9 @@ // 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.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.Encodings.Web; @@ -271,7 +273,47 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo _visitedComponentIdsInCurrentStreamingBatch?.Add(componentId); var componentState = (EndpointComponentState)GetComponentState(componentId); + + if (componentState.Component is CacheBoundary cacheBoundary) + { + if (cacheBoundary.Enabled && cacheBoundary.ResolvedCacheKey is { } cacheKey) + { + if (cacheBoundary.CachedData is not null) + { + base.WriteComponentHtml(componentId, output); + return; + } + + if (_cacheStore is not null) + { + var cacheCaptureWriter = new CacheBoundaryTextWriter(output, cacheBoundary.GetVaryByOptions()); + cacheCaptureWriter.StartCapture(); + base.WriteComponentHtml(componentId, cacheCaptureWriter); + var segments = cacheCaptureWriter.StopCapture(); + _cacheStore.Set(cacheKey, segments.Serialize(), new CacheStoreOptions + { + ExpiresAfter = cacheBoundary.ExpiresAfter, + ExpiresOn = cacheBoundary.ExpiresOn, + ExpiresSliding = cacheBoundary.ExpiresSliding, + Priority = cacheBoundary.Priority, + }); + return; + } + } + } + var renderBoundaryMarkers = allowBoundaryMarkers && componentState.StreamRendering; + var captureWriter = output as CacheBoundaryTextWriter; + var pausedCapture = false; + if (captureWriter is not null && captureWriter.IsCapturing && (IsHoleComponent(componentState.Component.GetType(), captureWriter.VaryBy) || renderBoundaryMarkers)) + { + pausedCapture = true; + captureWriter.PauseCapture(); + var renderModeName = componentState.Component is SSRRenderModeBoundary boundary2 + ? CacheSegment.GetRenderModeName(boundary2.RenderMode) + : null; + captureWriter.CreateHole(componentState.Component.GetType(), renderModeName, sequenceAndKey.Key); + } ComponentEndMarker? endMarkerOrNull = default; @@ -320,6 +362,24 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo output.Write(serializedEndRecord); output.Write("-->"); } + + if (pausedCapture) + { + captureWriter!.StartCapture(); + } + } + + private static readonly ConcurrentDictionary _cachedCacheExclusions = new(); + + internal static bool IsHoleComponent(Type componentType, CacheBoundaryVaryBy varyBy) + { + if (!_cachedCacheExclusions.TryGetValue(componentType, out var attr)) + { + attr = componentType.GetCustomAttribute(inherit: true); + _cachedCacheExclusions.TryAdd(componentType, attr); + } + + return attr is { Excluded: true } && (attr.VaryBy == CacheBoundaryVaryBy.None || (attr.VaryBy & varyBy) != attr.VaryBy); } internal static bool IsProgressivelyEnhancedNavigation(HttpRequest request) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 075ea3bb9831..5c8fbd575bcb 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -40,6 +40,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer { private readonly IServiceProvider _services; private readonly RazorComponentsServiceOptions _options; + private readonly ICacheBoundaryStore? _cacheStore; private Task? _servicesInitializedTask; private HttpContext _httpContext = default!; // Always set at the start of an inbound call private ResourceAssetCollection? _resourceCollection; @@ -60,11 +61,15 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log _services = serviceProvider; _options = serviceProvider.GetRequiredService>().Value; _logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer"); + _cacheStore = serviceProvider.GetService(); } 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 1febaa8f4af5..222d93db0533 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -18,6 +18,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(Excluded = true)] internal class SSRRenderModeBoundary : IComponent { private static readonly ConcurrentDictionary _componentTypeNameHashCache = new(); diff --git a/src/Components/Endpoints/test/CacheBoundaryJsonTest.cs b/src/Components/Endpoints/test/CacheBoundaryJsonTest.cs new file mode 100644 index 000000000000..3caab83e7aa9 --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryJsonTest.cs @@ -0,0 +1,264 @@ +// 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.Web; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class CacheBoundaryJsonTest +{ + [Fact] + public void AddHtml_AddsHtmlSegment() + { + var json = new CacheBoundaryJson(); + + json.AddHtml("

hello

"); + + Assert.Equal(1, json.Count); + var segment = GetSegments(json)[0]; + Assert.Equal(CacheSegmentKind.Html, segment.Kind); + Assert.Equal("

hello

", segment.Html); + Assert.Null(segment.ComponentType); + } + + [Fact] + public void AddHole_AddsHoleSegment() + { + var json = new CacheBoundaryJson(); + + json.AddHole(typeof(NotCacheBoundary)); + + Assert.Equal(1, json.Count); + var segment = GetSegments(json)[0]; + Assert.Equal(CacheSegmentKind.Hole, segment.Kind); + Assert.Equal(typeof(NotCacheBoundary), segment.ComponentType); + Assert.Null(segment.Html); + Assert.Null(segment.RenderModeName); + Assert.Null(segment.ComponentKey); + } + + [Fact] + public void AddHole_WithRenderModeAndKey() + { + var json = new CacheBoundaryJson(); + + json.AddHole(typeof(NotCacheBoundary), "InteractiveServer", "my-key"); + + var segment = GetSegments(json)[0]; + Assert.Equal("InteractiveServer", segment.RenderModeName); + Assert.Equal("my-key", segment.ComponentKey); + } + + [Fact] + public void AddHtml_ThrowsForNull() + { + var json = new CacheBoundaryJson(); + + Assert.Throws(() => json.AddHtml(null!)); + } + + [Fact] + public void AddHole_ThrowsForNullType() + { + var json = new CacheBoundaryJson(); + + Assert.Throws(() => json.AddHole(null!)); + } + + [Fact] + public void SerializeDeserialize_HtmlOnly() + { + var original = new CacheBoundaryJson(); + original.AddHtml("
cached
"); + original.AddHtml("

more

"); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + Assert.Equal(2, restored.Count); + var segments = GetSegments(restored); + Assert.Equal(CacheSegmentKind.Html, segments[0].Kind); + Assert.Equal("
cached
", segments[0].Html); + Assert.Equal(CacheSegmentKind.Html, segments[1].Kind); + Assert.Equal("

more

", segments[1].Html); + } + + [Fact] + public void SerializeDeserialize_MixedSegments() + { + var original = new CacheBoundaryJson(); + original.AddHtml("
cached
"); + original.AddHole(typeof(NotCacheBoundary)); + original.AddHtml("
also cached
"); + original.AddHole(typeof(CacheBoundary), "InteractiveWebAssembly", "key-1"); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + Assert.Equal(4, restored.Count); + var segments = GetSegments(restored); + + Assert.Equal(CacheSegmentKind.Html, segments[0].Kind); + Assert.Equal("
cached
", segments[0].Html); + + Assert.Equal(CacheSegmentKind.Hole, segments[1].Kind); + Assert.Equal(typeof(NotCacheBoundary), segments[1].ComponentType); + Assert.Null(segments[1].RenderModeName); + Assert.Null(segments[1].ComponentKey); + + Assert.Equal(CacheSegmentKind.Html, segments[2].Kind); + Assert.Equal("
also cached
", segments[2].Html); + + Assert.Equal(CacheSegmentKind.Hole, segments[3].Kind); + Assert.Equal(typeof(CacheBoundary), segments[3].ComponentType); + Assert.Equal("InteractiveWebAssembly", segments[3].RenderModeName); + Assert.Equal("key-1", segments[3].ComponentKey); + } + + [Fact] + public void SerializeDeserialize_EmptySegments() + { + var original = new CacheBoundaryJson(); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + Assert.Equal(0, restored.Count); + } + + [Fact] + public void SerializeDeserialize_PreservesHtmlWithSpecialCharacters() + { + var html = "
Hello world & goodbye
"; + var original = new CacheBoundaryJson(); + original.AddHtml(html); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + Assert.Equal(html, GetSegments(restored)[0].Html); + } + + [Fact] + public void SerializeDeserialize_PreservesIntKey() + { + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: 42); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + var segment = GetSegments(restored)[0]; + Assert.IsType(segment.ComponentKey); + Assert.Equal(42, segment.ComponentKey); + } + + [Fact] + public void SerializeDeserialize_PreservesGuidKey() + { + var guid = Guid.NewGuid(); + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: guid); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + var segment = GetSegments(restored)[0]; + Assert.IsType(segment.ComponentKey); + Assert.Equal(guid, segment.ComponentKey); + } + + [Fact] + public void SerializeDeserialize_PreservesStringKey() + { + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: "my-key"); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + var segment = GetSegments(restored)[0]; + Assert.IsType(segment.ComponentKey); + Assert.Equal("my-key", segment.ComponentKey); + } + + [Fact] + public void SerializeDeserialize_PreservesNullKey() + { + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: null); + + var serialized = original.Serialize(); + var restored = CacheBoundaryJson.Deserialize(serialized); + + var segment = GetSegments(restored)[0]; + Assert.Null(segment.ComponentKey); + } + + [Fact] + public void Deserialize_ThrowsForNull() + { + Assert.Throws(() => CacheBoundaryJson.Deserialize(null!)); + } + + [Fact] + public void Deserialize_ThrowsForInvalidJson() + { + Assert.ThrowsAny(() => CacheBoundaryJson.Deserialize("not valid json")); + } + + [Fact] + public void Deserialize_ThrowsForUnknownSegmentType() + { + var json = """[{"Type":"unknown","Content":"test"}]"""; + + var ex = Assert.Throws(() => CacheBoundaryJson.Deserialize(json)); + Assert.Contains("Unknown cache segment type", ex.Message); + } + + [Fact] + public void Deserialize_ThrowsForHoleMissingComponentType() + { + var json = """[{"Type":"hole","Content":null}]"""; + + var ex = Assert.Throws(() => CacheBoundaryJson.Deserialize(json)); + Assert.Contains("missing component type", ex.Message); + } + + [Fact] + public void Deserialize_ThrowsForUnresolvableComponentType() + { + var json = """[{"Type":"hole","Content":"Some.Fake.Type, FakeAssembly"}]"""; + + var ex = Assert.Throws(() => CacheBoundaryJson.Deserialize(json)); + Assert.Contains("Could not resolve hole component type", ex.Message); + } + + [Fact] + public void GetRenderModeName_ReturnsCorrectNames() + { + Assert.Null(CacheSegment.GetRenderModeName(null)); + Assert.Equal("InteractiveServer", CacheSegment.GetRenderModeName(RenderMode.InteractiveServer)); + Assert.Equal("InteractiveWebAssembly", CacheSegment.GetRenderModeName(RenderMode.InteractiveWebAssembly)); + Assert.Equal("InteractiveAuto", CacheSegment.GetRenderModeName(RenderMode.InteractiveAuto)); + } + + [Fact] + public void GetRenderModeName_ThrowsForUnsupportedMode() + { + var ex = Assert.Throws(() => CacheSegment.GetRenderModeName(new TestRenderMode())); + Assert.Contains("Unsupported render mode type", ex.Message); + } + + private static List GetSegments(CacheBoundaryJson json) + { + var list = new List(); + foreach (var segment in json) + { + list.Add(segment); + } + return list; + } + + private sealed class TestRenderMode : IComponentRenderMode; +} diff --git a/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs b/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs new file mode 100644 index 000000000000..6400b9f60a03 --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs @@ -0,0 +1,299 @@ +// 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; + +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_VaryByUser_Disabled_SameKeyRegardlessOfUser() + { + var component = CreateComponent(varyByUser: false); + var ctx1 = CreateHttpContext(userName: "alice"); + var ctx2 = CreateHttpContext(userName: "bob"); + + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); + + Assert.Equal(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); + } + + 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) + { + 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) + { + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Name, userName)], "test")); + } + + return httpContext; + } +} diff --git a/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs new file mode 100644 index 000000000000..23847e84d774 --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.AspNetCore.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 MissingDependencies_FallsBackToChildContent() + { + var component = new CacheBoundary + { + ChildContent = builder => builder.AddContent(0, "hello"), + }; + + var frames = await RenderComponent(component); + + AssertContainsText(frames, "hello"); + } + + [Fact] + public async Task DeserializationFailure_FallsBackToChildContent_AndLogsWarning() + { + var testLogger = new TestLogger(); + var httpContext = CreateHttpContext(); + httpContext.RequestServices = new TestServiceProviderWithLogger(testLogger); + + var store = new TestCacheStore { ReturnForAnyKey = "NOT VALID JSON {{{" }; + + var component = new CacheBoundary + { + ChildContent = builder => builder.AddContent(0, "fallback"), + CacheStore = store, + 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 HttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + 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 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."); + } + + private sealed class TestCacheStore : ICacheBoundaryStore + { + public Dictionary Data { get; } = new(); + public string? ReturnForAnyKey { get; set; } + + public string? Get(string key) + => ReturnForAnyKey ?? (Data.TryGetValue(key, out var value) ? value : null); + + public void Set(string key, string json, CacheStoreOptions options = default) + => Data[key] = json; + + public void Dispose() { } + } + + 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..9d8625abe3d6 --- /dev/null +++ b/src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs @@ -0,0 +1,131 @@ +// 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; + +public class CacheBoundaryTextWriterTest +{ + [Fact] + public void Write_AlwaysForwardsToInner() + { + var inner = new StringWriter(); + var writer = CreateWriter(inner); + + writer.Write("hello"); + + Assert.Equal("hello", inner.ToString()); + } + + [Fact] + public void Write_WithoutStartCapture_DoesNotCaptureSegments() + { + var writer = CreateWriter(); + + writer.Write("not captured"); + var segments = writer.StopCapture(); + + Assert.Equal(0, segments.Count); + } + + [Fact] + public void StartCapture_ThenWrite_CapturesHtml() + { + var writer = CreateWriter(); + + writer.StartCapture(); + writer.Write("captured"); + var segments = writer.StopCapture(); + + Assert.Equal(1, segments.Count); + } + + [Fact] + public void WriteChar_AlwaysForwardsToInner() + { + var inner = new StringWriter(); + var writer = CreateWriter(inner); + + writer.Write('x'); + + Assert.Equal("x", inner.ToString()); + } + + [Fact] + public void WriteChar_DuringCapture_IsCaptured() + { + var writer = CreateWriter(); + + writer.StartCapture(); + writer.Write('a'); + writer.Write('b'); + var segments = writer.StopCapture(); + + Assert.Equal(1, segments.Count); + } + + [Fact] + public void PauseCapture_FlushesBufferAsHtmlSegment() + { + var writer = CreateWriter(); + + writer.StartCapture(); + writer.Write("first"); + writer.PauseCapture(); + writer.Write("gap"); + writer.StartCapture(); + writer.Write("second"); + var segments = writer.StopCapture(); + + // "first" flushed by PauseCapture, "second" flushed by StopCapture + Assert.Equal(2, segments.Count); + } + + [Fact] + public void PauseCapture_WithEmptyBuffer_DoesNotAddSegment() + { + var writer = CreateWriter(); + + writer.StartCapture(); + writer.PauseCapture(); + var segments = writer.StopCapture(); + + Assert.Equal(0, segments.Count); + } + + [Fact] + public void CreateHole_AddsHoleSegment() + { + var writer = CreateWriter(); + + writer.StartCapture(); + writer.Write(""); + writer.PauseCapture(); + writer.CreateHole(typeof(FakeHoleComponent)); + writer.StartCapture(); + writer.Write(""); + var segments = writer.StopCapture(); + + // html + hole + html = 3 segments + Assert.Equal(3, segments.Count); + } + + [Fact] + public void Encoding_MatchesInnerWriter() + { + var inner = new StringWriter(); + var writer = CreateWriter(inner); + + Assert.Equal(inner.Encoding, writer.Encoding); + } + + private static CacheBoundaryTextWriter CreateWriter(TextWriter inner = null) + { + return new CacheBoundaryTextWriter(inner ?? new StringWriter(), CacheBoundaryVaryBy.None); + } + + private class FakeHoleComponent : IComponent + { + 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..cc818acdadf9 --- /dev/null +++ b/src/Components/Endpoints/test/IsHoleComponentTest.cs @@ -0,0 +1,139 @@ +// 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.Authorization; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Sections; +using Microsoft.AspNetCore.Components.Web; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class IsHoleComponentTest +{ + private static readonly CacheBoundaryVaryBy DefaultVaryBy = CacheBoundaryVaryBy.None; + private static readonly CacheBoundaryVaryBy VaryByUser = CacheBoundaryVaryBy.User; + + [Fact] + public void EditForm_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(EditForm), DefaultVaryBy)); + } + + [Fact] + public void ValidationSummary_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(ValidationSummary), DefaultVaryBy)); + } + + [Fact] + public void ValidationMessage_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(ValidationMessage), DefaultVaryBy)); + } + + [Fact] + public void InputText_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(InputText), DefaultVaryBy)); + } + + [Fact] + public void InputNumber_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(InputNumber), DefaultVaryBy)); + } + + [Fact] + public void InputCheckbox_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(InputCheckbox), DefaultVaryBy)); + } + + [Fact] + public void AntiforgeryToken_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(AntiforgeryToken), DefaultVaryBy)); + } + + [Fact] + public void NotCacheBoundary_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(NotCacheBoundary), DefaultVaryBy)); + } + + [Fact] + public void SSRRenderModeBoundary_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(SSRRenderModeBoundary), DefaultVaryBy)); + } + + [Fact] + public void HeadOutlet_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(HeadOutlet), DefaultVaryBy)); + } + + [Fact] + public void SectionOutlet_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(SectionOutlet), DefaultVaryBy)); + } + + [Fact] + public void SectionContent_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(SectionContent), DefaultVaryBy)); + } + + [Fact] + public void AuthorizeView_IsHole_WhenNotVaryByUser() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(AuthorizeView), DefaultVaryBy)); + } + + [Fact] + public void AuthorizeView_IsNotHole_WhenVaryByUser() + { + Assert.False(EndpointHtmlRenderer.IsHoleComponent(typeof(AuthorizeView), VaryByUser)); + } + + [Fact] + public void AuthorizeViewSubclass_IsHole_WhenNotVaryByUser() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(CustomAuthorizeView), DefaultVaryBy)); + } + + [Fact] + public void AuthorizeViewSubclass_IsNotHole_WhenVaryByUser() + { + Assert.False(EndpointHtmlRenderer.IsHoleComponent(typeof(CustomAuthorizeView), VaryByUser)); + } + + [Fact] + public void RegularComponent_IsNotHole() + { + Assert.False(EndpointHtmlRenderer.IsHoleComponent(typeof(ComponentBase), DefaultVaryBy)); + } + + [Fact] + public void CustomComponent_IsNotHole() + { + Assert.False(EndpointHtmlRenderer.IsHoleComponent(typeof(PlainComponent), DefaultVaryBy)); + } + + [Fact] + public void CustomInputBaseDescendant_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(CustomInput), DefaultVaryBy)); + } + + private class PlainComponent : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) { } + } + + private class CustomAuthorizeView : AuthorizeView { } + + private class CustomInput : InputText { } +} diff --git a/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj b/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj index c9e5d41f10d3..507c1c87bf96 100644 --- a/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj +++ b/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj @@ -28,6 +28,7 @@ +
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/Web/src/Forms/AntiforgeryToken.cs b/src/Components/Web/src/Forms/AntiforgeryToken.cs index aac81a69ecf3..6a5809fe4141 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(Excluded = true)] public class AntiforgeryToken : IComponent { private RenderHandle _handle; diff --git a/src/Components/Web/src/Forms/EditForm.cs b/src/Components/Web/src/Forms/EditForm.cs index f59ba91e074c..2aebf9ee2d5f 100644 --- a/src/Components/Web/src/Forms/EditForm.cs +++ b/src/Components/Web/src/Forms/EditForm.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// /// Renders a form element that cascades an to descendants. /// +[CacheBoundaryPolicy(Excluded = true)] public class EditForm : ComponentBase { private readonly Func _handleSubmitDelegate; // Cache to avoid per-render allocations diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index 9b2a62e39021..d24d844ef2d0 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// integrates with an , which must be supplied /// as a cascading parameter. /// +[CacheBoundaryPolicy(Excluded = true)] public abstract class InputBase : ComponentBase, IDisposable { private readonly EventHandler _validationStateChangedHandler; diff --git a/src/Components/Web/src/Forms/ValidationMessage.cs b/src/Components/Web/src/Forms/ValidationMessage.cs index cc84a0ab7a2a..9cf0b2f2c140 100644 --- a/src/Components/Web/src/Forms/ValidationMessage.cs +++ b/src/Components/Web/src/Forms/ValidationMessage.cs @@ -9,6 +9,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// /// Displays a list of validation messages for a specified field within a cascaded . /// +[CacheBoundaryPolicy(Excluded = true)] public class ValidationMessage : ComponentBase, IDisposable { private EditContext? _previousEditContext; diff --git a/src/Components/Web/src/Forms/ValidationSummary.cs b/src/Components/Web/src/Forms/ValidationSummary.cs index e2580fd0cf97..36f159a19f99 100644 --- a/src/Components/Web/src/Forms/ValidationSummary.cs +++ b/src/Components/Web/src/Forms/ValidationSummary.cs @@ -12,6 +12,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// /// Displays a list of validation messages from a cascaded . /// +[CacheBoundaryPolicy(Excluded = true)] public class ValidationSummary : ComponentBase, IDisposable { private EditContext? _previousEditContext; diff --git a/src/Components/Web/src/Head/HeadOutlet.cs b/src/Components/Web/src/Head/HeadOutlet.cs index 313ec37b550e..ff6f7202c1f8 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(Excluded = true)] public sealed class HeadOutlet : ComponentBase { private const string GetAndRemoveExistingTitle = "Blazor._internal.PageTitle.getAndRemoveExistingTitle"; diff --git a/src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs b/src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs new file mode 100644 index 000000000000..98b6454ed9e1 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs @@ -0,0 +1,123 @@ +// 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 Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class CacheBoundaryTest : ServerTestBase>> +{ + public CacheBoundaryTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); + + protected override void InitializeAsyncCore() + { + base.InitializeAsyncCore(); + Navigate($"{ServerPathBase}/cache-component/clear"); + } + + [Fact] + public void CacheBoundaryCachesData() + { + Navigate($"{ServerPathBase}/cache-component"); + var testElement = Browser.FindElement(By.Id("test-1")); + var cachedValue = testElement.FindElement(By.CssSelector(".cached")).Text; + + Navigate($"{ServerPathBase}/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($"{ServerPathBase}/cache-component"); + var testElement = Browser.FindElement(By.Id("test-2")); + var firstValue = testElement.FindElement(By.CssSelector(".cached")).Text; + + Navigate($"{ServerPathBase}/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($"{ServerPathBase}/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 NestedCacheBoundaryDoesNotExecuteOnOuterCacheHit() + { + Navigate($"{ServerPathBase}/cache-component"); + Browser.Exists(By.Id("inner-cached")); + var renderCount = GetRenderCount(); + Assert.Equal(1, renderCount); + + Navigate($"{ServerPathBase}/cache-component"); + Browser.Exists(By.Id("inner-cached")); + renderCount = GetRenderCount(); + Assert.Equal(1, renderCount); + } + + [Fact] + public void CacheBoundaryInLoopUsesVaryByForDistinctEntries() + { + Navigate($"{ServerPathBase}/cache-component"); + var loopItems = Browser.FindElement(By.Id("test-5")).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($"{ServerPathBase}/cache-component"); + for (var i = 0; i < 3; i++) + { + var index = i; + Browser.Equal(firstRenderValues[index], () => + Browser.FindElement(By.Id("test-5")) + .FindElements(By.CssSelector(".loop-item"))[index] + .FindElement(By.CssSelector(".cached-value")).Text); + } + } + + private int GetRenderCount() + { + Navigate($"{ServerPathBase}/cache-component/render-count"); + var body = Browser.FindElement(By.TagName("body")).Text; + return int.Parse(body, CultureInfo.InvariantCulture); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index 12945736f951..240cdf6f7d38 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using System.Web; using Components.TestServer.RazorComponents; +using Components.TestServer.RazorComponents.Pages.CacheBoundaryTest; using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; using Microsoft.AspNetCore.Components.Endpoints; @@ -140,6 +141,22 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen app.UseAntiforgery(); app.UseEndpoints(endpoints => { + endpoints.MapGet("cache-component/clear", (HttpContext context) => + { + var storeType = typeof(RazorComponentsServiceOptions).Assembly + .GetType("Microsoft.AspNetCore.Components.Endpoints.ICacheBoundaryStore") + ?? throw new InvalidOperationException("ICacheBoundaryStore type not found. The internal type may have been renamed or moved."); + var store = context.RequestServices.GetService(storeType) + ?? throw new InvalidOperationException("ICacheBoundaryStore is not registered in DI."); + var clearMethod = storeType.GetMethod("Clear") + ?? throw new InvalidOperationException("ICacheBoundaryStore.Clear() method not found."); + clearMethod.Invoke(store, null); + InnerCachedComponent.ResetRenderCount(); + }); + endpoints.MapGet("cache-component/render-count", () => + { + return Results.Ok(InnerCachedComponent.RenderCount); + }); endpoints.MapRazorComponents() .AddAdditionalAssemblies(Assembly.Load("TestContentPackage")); }); 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..35685d44cb40 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor @@ -0,0 +1,62 @@ +@page "/cache-component" +@using Microsoft.AspNetCore.Components.Forms + +

CacheBoundary

+ +
+ +

@Guid.NewGuid()

+ +

@Guid.NewGuid()

+
+
+

@Guid.NewGuid()

+
+ +
+ +

@Guid.NewGuid()

+ +

@Guid.NewGuid()

+
+
+

@Guid.NewGuid()

+
+ +
+ + + + + + +

@Message

+
+
+
+ +
+ +

@Guid.NewGuid()

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

@Guid.NewGuid()

+
+
+ } +
+ +@code +{ + [SupplyParameterFromForm(FormName = "editFormTest")] + private string Message { get; set; } = "never"; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/InnerCachedComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/InnerCachedComponent.razor new file mode 100644 index 000000000000..42b0e254429e --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/InnerCachedComponent.razor @@ -0,0 +1,15 @@ +

@Guid.NewGuid()

+ +@code +{ + private static int _renderCount; + + public static int RenderCount => _renderCount; + + public static void ResetRenderCount() => Interlocked.Exchange(ref _renderCount, 0); + + protected override void OnInitialized() + { + Interlocked.Increment(ref _renderCount); + } +}