From ace51a83cd75c0d376905a33c626ece371cbcc41 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Mon, 13 Apr 2026 16:24:16 +0200 Subject: [PATCH 1/6] Implement CacheComponent for Blazor SSR output caching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/CacheComponent/CacheComponent.cs | 221 +++++++++++++ .../src/CacheComponent/CacheComponentJson.cs | 155 +++++++++ .../CacheComponentKeyResolver.cs | 90 ++++++ .../src/CacheComponent/CacheComponentStore.cs | 34 ++ .../CacheComponent/CacheComponentVaryBy.cs | 14 + .../src/CacheComponent/CacheStoreOptions.cs | 11 + .../MemoryCacheComponentStore.cs | 59 ++++ .../src/CacheComponent/NotCacheComponent.cs | 26 ++ ...orComponentsServiceCollectionExtensions.cs | 2 + .../RazorComponentsServiceOptions.cs | 7 + .../Endpoints/src/PublicAPI.Unshipped.txt | 34 ++ .../src/Rendering/CacheComponentTextWriter.cs | 77 +++++ .../EndpointHtmlRenderer.Streaming.cs | 139 ++++++++ .../src/Rendering/EndpointHtmlRenderer.cs | 2 + .../Endpoints/test/CacheComponentJsonTest.cs | 288 +++++++++++++++++ .../test/CacheComponentKeyResolverTest.cs | 303 ++++++++++++++++++ .../test/CacheComponentTextWriterTest.cs | 144 +++++++++ .../test/E2ETest/Tests/CacheComponentTest.cs | 149 +++++++++ ...omponentEndpointsNoInteractivityStartup.cs | 17 + .../CacheComponentTest.razor | 71 ++++ .../InnerCachedComponent.razor | 15 + 21 files changed, 1858 insertions(+) create mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponent.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs create mode 100644 src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs create mode 100644 src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs create mode 100644 src/Components/Endpoints/test/CacheComponentJsonTest.cs create mode 100644 src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs create mode 100644 src/Components/Endpoints/test/CacheComponentTextWriterTest.cs create mode 100644 src/Components/test/E2ETest/Tests/CacheComponentTest.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs new file mode 100644 index 000000000000..b55bd964edbb --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs @@ -0,0 +1,221 @@ +// 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.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components; + +/// +/// A component that caches the rendered HTML output of its child content during +/// server-side rendering (SSR). On cache hit, child components are not instantiated +/// or rendered, preventing unnecessary data fetching and computation. +/// +public sealed class CacheComponent : ComponentBase +{ + /// + /// Gets or sets the content to be cached. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets an explicit cache key for additional disambiguation. Only needed + /// when a reusable component uses internally and + /// multiple instances of that component appear on the same page. + /// + [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 the duration, from the time the cache entry was added, when it should be evicted. + /// + [Parameter] + public TimeSpan? ExpiresAfter { get; set; } + + /// + /// Gets or sets the exact the cache entry should be evicted. + /// + [Parameter] + public DateTimeOffset? ExpiresOn { get; set; } + + /// + /// Gets or sets the duration from last access that the cache entry should be evicted. + /// + [Parameter] + public TimeSpan? ExpiresSliding { 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; } + + // Injected cache store — registered as singleton in DI. + [Inject] internal CacheComponentStore? CacheStore { get; set; } + + // HttpContext is cascaded by the SSR infrastructure. + [CascadingParameter] internal HttpContext? HttpContext { get; set; } + + internal string? ResolvedCacheKey { get; private set; } + internal string? CachedData { get; private set; } + + internal CacheComponentVaryBy GetVaryByOptions() => new() + { + VaryByQuery = VaryByQuery is not null, + VaryByRoute = VaryByRoute is not null, + VaryByHeader = VaryByHeader is not null, + VaryByCookie = VaryByCookie is not null, + VaryByUser = VaryByUser is true, + VaryByCulture = VaryByCulture is true, + }; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Enabled && CacheStore is not null && HttpContext is { } httpContext) + { + ResolvedCacheKey = CacheComponentKeyResolver.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!); + var renderMode = segment.ReconstructRenderMode(); + if (renderMode is not null) + { + builder.AddComponentRenderMode(renderMode); + } + 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 CacheComponentJson? cacheJson) + { + cacheJson = null; + + if (string.IsNullOrEmpty(CachedData)) + { + return false; + } + + try + { + cacheJson = CacheComponentJson.Deserialize(CachedData); + + return cacheJson.Count > 0; + } + catch (Exception ex) + { + HttpContext?.RequestServices.GetService() + ?.CreateLogger() + .LogWarning(ex, "Failed to restore CacheComponent from cached data. Falling back to fresh render."); + return false; + } + } +} diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs new file mode 100644 index 000000000000..cd424e4d6b7a --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs @@ -0,0 +1,155 @@ +// 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 CacheComponentJson +{ + 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, string? 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 = segment.ComponentKey, + }, + _ => throw new InvalidOperationException($"Unknown segment kind: {segment.Kind}"), + }; + } + + return JsonSerializer.Serialize(entries, CacheJsonContext.Default.JsonCacheSegmentArray); + } + + public static CacheComponentJson 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 CacheComponentJson(); + 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, entry.Key); + break; + default: + throw new InvalidOperationException($"Unknown cache segment type: '{entry.Type}'."); + } + } + + return result; + } + + 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; } + } + + [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 string? ComponentKey { get; } + + private CacheSegment(CacheSegmentKind kind, string? html, Type? componentType, string? renderModeName = null, string? 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, string? componentKey = null) + => new(CacheSegmentKind.Hole, html: null, componentType, renderModeName, componentKey); + + /// + /// Reconstructs the from the serialized name, or returns null if no render mode was cached. + /// + public IComponentRenderMode? ReconstructRenderMode() + { + return RenderModeName switch + { + null => null, + "InteractiveServer" => RenderMode.InteractiveServer, + "InteractiveWebAssembly" => RenderMode.InteractiveWebAssembly, + "InteractiveAuto" => RenderMode.InteractiveAuto, + _ => throw new InvalidOperationException($"Unknown cached render mode: '{RenderModeName}'."), + }; + } + + 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/CacheComponent/CacheComponentKeyResolver.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs new file mode 100644 index 000000000000..a7f96411a7fe --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs @@ -0,0 +1,90 @@ +// 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.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal static class CacheComponentKeyResolver +{ + internal static string ComputeKey(CacheComponent cacheComponent, HttpContext httpContext) + { + var sb = new StringBuilder(); + + if (cacheComponent.ChildContent is { } childContent) + { + sb.Append(childContent.Method.DeclaringType?.FullName) + .Append('.') + .Append(childContent.Method.Name); + } + else + { + sb.Append(nameof(CacheComponent)); + } + + if (cacheComponent.CacheKey is { } cacheKey) + { + sb.Append('.').Append(cacheKey); + } + + AppendVaryByValues(sb, cacheComponent, httpContext); + + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()))); + } + + private static void AppendVaryByValues(StringBuilder sb, CacheComponent cacheComponent, HttpContext httpContext) + { + var request = httpContext.Request; + + if (cacheComponent.VaryByQuery is { } varyByQuery) + { + foreach (var name in varyByQuery.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append('.').Append(name).Append('=').Append(request.Query[name]); + } + } + + if (cacheComponent.VaryByRoute is { } varyByRoute) + { + foreach (var name in varyByRoute.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append('.').Append(name).Append('=').Append(request.RouteValues[name]); + } + } + + if (cacheComponent.VaryByHeader is { } varyByHeader) + { + foreach (var name in varyByHeader.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append('.').Append(name).Append('=').Append(request.Headers[name]); + } + } + + if (cacheComponent.VaryByCookie is { } varyByCookie) + { + foreach (var name in varyByCookie.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append('.').Append(name).Append('=').Append(request.Cookies[name]); + } + } + + if (cacheComponent.VaryByUser is true) + { + sb.Append(".user=").Append(httpContext.User.Identity?.Name); + } + + if (cacheComponent.VaryByCulture is true) + { + sb.Append(".culture=").Append(CultureInfo.CurrentCulture.Name) + .Append('.').Append(CultureInfo.CurrentUICulture.Name); + } + + if (cacheComponent.VaryBy is { } varyBy) + { + sb.Append('.').Append(varyBy); + } + } +} diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs new file mode 100644 index 000000000000..5319fd97648e --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs @@ -0,0 +1,34 @@ +// 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 abstract class CacheComponentStore : IDisposable +{ + protected static readonly TimeSpan DefaultExpiration = TimeSpan.FromSeconds(30); + + /// + /// Gets a cached JSON template for the specified key, or null on cache miss. + /// + public abstract string? Get(string key); + + /// + /// Stores a JSON template for the specified key. + /// + public abstract void Set(string key, string json, CacheStoreOptions options = default); + + /// + /// Removes all cached entries. Used primarily for testing scenarios. + /// + public virtual void Clear() + { + } + + /// + public virtual void Dispose() + { + } +} diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs new file mode 100644 index 000000000000..9f5bf8c5f8ec --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.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. + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed class CacheComponentVaryBy +{ + public bool VaryByQuery { get; set; } + public bool VaryByRoute { get; set; } + public bool VaryByHeader { get; set; } + public bool VaryByCookie { get; set; } + public bool VaryByUser { get; set; } + public bool VaryByCulture { get; set; } +} diff --git a/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs b/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs new file mode 100644 index 000000000000..dfc93c2f5cba --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal readonly struct CacheStoreOptions +{ + public TimeSpan? ExpiresAfter { get; init; } + public DateTimeOffset? ExpiresOn { get; init; } + public TimeSpan? ExpiresSliding { get; init; } +} diff --git a/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs b/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs new file mode 100644 index 000000000000..912bb3698152 --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs @@ -0,0 +1,59 @@ +// 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 MemoryCacheComponentStore : CacheComponentStore +{ + private readonly MemoryCache _cache; + + public MemoryCacheComponentStore(IOptions options) + { + _cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = options.Value.CacheComponentSizeLimit, + }); + } + + public override string? Get(string key) + { + _cache.TryGetValue(key, out string? cached); + return cached; + } + + public override 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; + } + else if (options.ExpiresOn.HasValue) + { + entryOptions.AbsoluteExpiration = options.ExpiresOn.Value; + } + else + { + entryOptions.AbsoluteExpirationRelativeToNow = options.ExpiresAfter ?? DefaultExpiration; + } + + _cache.Set(key, json, entryOptions); + } + + public override void Clear() + { + _cache.Clear(); + } + + public override void Dispose() + { + _cache.Dispose(); + } +} diff --git a/src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs b/src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs new file mode 100644 index 000000000000..c418222fd037 --- /dev/null +++ b/src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs @@ -0,0 +1,26 @@ +// 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. +/// +public sealed class NotCacheComponent : 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 df998d59c72a..24ed304b0cdd 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -75,6 +75,8 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddScoped(); services.AddTempData(); + services.TryAddSingleton(sp => + new MemoryCacheComponentStore(sp.GetRequiredService>())); services.TryAddScoped(); RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveWebAssembly); diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs index 85e883dde0d9..dd7234f6cf64 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs @@ -103,4 +103,11 @@ 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. + /// + public long CacheComponentSizeLimit { get; set; } = 100_000_000; } diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index 2ebef8b69595..e4c258c25d51 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,6 +1,40 @@ #nullable enable Microsoft.AspNetCore.Components.Endpoints.BasePath Microsoft.AspNetCore.Components.Endpoints.BasePath.BasePath() -> void +Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.CacheComponentSizeLimit.get -> long +Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.CacheComponentSizeLimit.set -> void +Microsoft.AspNetCore.Components.CacheComponent +Microsoft.AspNetCore.Components.CacheComponent.CacheComponent() -> void +Microsoft.AspNetCore.Components.CacheComponent.CacheKey.get -> string? +Microsoft.AspNetCore.Components.CacheComponent.CacheKey.set -> void +Microsoft.AspNetCore.Components.CacheComponent.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.CacheComponent.ChildContent.set -> void +Microsoft.AspNetCore.Components.CacheComponent.Enabled.get -> bool +Microsoft.AspNetCore.Components.CacheComponent.Enabled.set -> void +Microsoft.AspNetCore.Components.CacheComponent.ExpiresAfter.get -> System.TimeSpan? +Microsoft.AspNetCore.Components.CacheComponent.ExpiresAfter.set -> void +Microsoft.AspNetCore.Components.CacheComponent.ExpiresOn.get -> System.DateTimeOffset? +Microsoft.AspNetCore.Components.CacheComponent.ExpiresOn.set -> void +Microsoft.AspNetCore.Components.CacheComponent.ExpiresSliding.get -> System.TimeSpan? +Microsoft.AspNetCore.Components.CacheComponent.ExpiresSliding.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryBy.get -> string? +Microsoft.AspNetCore.Components.CacheComponent.VaryBy.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryByCookie.get -> string? +Microsoft.AspNetCore.Components.CacheComponent.VaryByCookie.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryByCulture.get -> bool? +Microsoft.AspNetCore.Components.CacheComponent.VaryByCulture.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryByHeader.get -> string? +Microsoft.AspNetCore.Components.CacheComponent.VaryByHeader.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryByQuery.get -> string? +Microsoft.AspNetCore.Components.CacheComponent.VaryByQuery.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryByRoute.get -> string? +Microsoft.AspNetCore.Components.CacheComponent.VaryByRoute.set -> void +Microsoft.AspNetCore.Components.CacheComponent.VaryByUser.get -> bool? +Microsoft.AspNetCore.Components.CacheComponent.VaryByUser.set -> void +Microsoft.AspNetCore.Components.NotCacheComponent +Microsoft.AspNetCore.Components.NotCacheComponent.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.NotCacheComponent.ChildContent.set -> void +Microsoft.AspNetCore.Components.NotCacheComponent.NotCacheComponent() -> 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/CacheComponentTextWriter.cs b/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs new file mode 100644 index 000000000000..2e6c50fc3db7 --- /dev/null +++ b/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs @@ -0,0 +1,77 @@ +// 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 CacheComponentTextWriter : TextWriter +{ + private readonly TextWriter _inner; + private readonly CacheComponentJson _segments = new(); + private readonly StringBuilder _buffer = new(); + private bool _capturing; + + public CacheComponentTextWriter(TextWriter inner, CacheComponentVaryBy varyBy) + { + _inner = inner; + VaryBy = varyBy; + } + + public CacheComponentVaryBy VaryBy { get; set; } + + public bool IsCapturing => _capturing; + + public override Encoding Encoding => _inner.Encoding; + + public override void Write(char value) + { + _inner.Write(value); + + if (_capturing) + { + _buffer.Append(value); + } + } + + public override void Write(string? value) + { + _inner.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, string? componentKey = null) + { + _segments.AddHole(componentType, renderModeName, componentKey); + } + + public CacheComponentJson StopCapture() + { + _capturing = false; + + if (_buffer.Length > 0) + { + _segments.AddHtml(_buffer.ToString()); + _buffer.Clear(); + } + return _segments; + } +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index 50728a8c3271..c6ffa9a70e6f 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -270,8 +271,48 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo _visitedComponentIdsInCurrentStreamingBatch?.Add(componentId); var componentState = (EndpointComponentState)GetComponentState(componentId); + var componentType = componentState.Component.GetType(); + + if (componentType == typeof(CacheComponent)) + { + var cacheComponent = (CacheComponent)componentState.Component; + if (cacheComponent.Enabled && cacheComponent.ResolvedCacheKey is { } cacheKey) + { + if (cacheComponent.CachedData is not null) + { + base.WriteComponentHtml(componentId, output); + return; + } + + if (_cacheStore is not null) + { + var cacheCaptureWriter = new CacheComponentTextWriter(output, cacheComponent.GetVaryByOptions()); + cacheCaptureWriter.StartCapture(); + base.WriteComponentHtml(componentId, cacheCaptureWriter); + var segments = cacheCaptureWriter.StopCapture(); + _cacheStore.Set(cacheKey, segments.Serialize(), new CacheStoreOptions + { + ExpiresAfter = cacheComponent.ExpiresAfter, + ExpiresOn = cacheComponent.ExpiresOn, + ExpiresSliding = cacheComponent.ExpiresSliding, + }); + return; + } + } + } + var renderBoundaryMarkers = allowBoundaryMarkers && componentState.StreamRendering; + var captureWriter = output as CacheComponentTextWriter; + var pausedCapture = false; + if (captureWriter is not null && captureWriter.IsCapturing && (IsHoleComponent(componentType, captureWriter.VaryBy) || renderBoundaryMarkers)) + { + pausedCapture = true; + captureWriter.PauseCapture(); + var (renderModeName, componentKey) = ExtractHoleMetadata(componentId, componentState); + captureWriter.CreateHole(componentType, renderModeName, componentKey); + } + ComponentEndMarker? endMarkerOrNull = default; if (componentState.Component is SSRRenderModeBoundary boundary) @@ -322,6 +363,104 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo output.Write(serializedEndRecord); output.Write("-->"); } + + if (pausedCapture) + { + captureWriter!.StartCapture(); + } + } + + // Determines whether a component must be rendered as a "hole" (uncached placeholder) + // in the cache template. Hole components are excluded from cached HTML and re-rendered + // fresh on every request, even on cache hits. + private static bool IsHoleComponent(Type componentType, CacheComponentVaryBy varyBy) + { + // Security: AuthorizeView is a hole unless VaryByUser is set, to avoid + // serving cached auth state to the wrong user. + if (componentType == typeof(Authorization.AuthorizeView) && !varyBy.VaryByUser) + { + return true; + } + + // Form components are holes because they contain antiforgery tokens and + // user-specific state that must not be served from cache. + if (componentType == typeof(EditForm) + || componentType == typeof(ValidationSummary)) + { + return true; + } + + // InputBase and ValidationMessage are generic — check the hierarchy + if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(ValidationMessage<>)) + { + return true; + } + + if (IsInputBaseDescendant(componentType)) + { + return true; + } + + return componentType == typeof(AntiforgeryToken) + || componentType == typeof(NotCacheComponent) + || componentType == typeof(SSRRenderModeBoundary) + || componentType == typeof(Web.HeadOutlet) + || componentType == typeof(Sections.SectionOutlet) + || componentType == typeof(Sections.SectionContent); + } + + private static bool IsInputBaseDescendant(Type componentType) + { + while (componentType is not null && componentType != typeof(object)) + { + if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(InputBase<>)) + { + return true; + } + componentType = componentType.BaseType!; + } + return false; + } + + private (string? RenderModeName, string? ComponentKey) ExtractHoleMetadata(int componentId, EndpointComponentState componentState) + { + var parentState = componentState.ParentComponentState; + if (parentState is null) + { + return (null, null); + } + + var parentFrames = GetCurrentRenderTreeFrames(parentState.ComponentId); + var frames = parentFrames.Array; + var count = parentFrames.Count; + + for (var i = 0; i < count; i++) + { + ref var frame = ref frames[i]; + if (frame.FrameType == RenderTreeFrameType.Component && frame.ComponentId == componentId) + { + var endIndex = i + frame.ComponentSubtreeLength; + var renderModeName = ExtractRenderMode(frames, i, endIndex); + var componentKey = frame.ComponentKey as string; + + return (renderModeName, componentKey); + } + } + + return (null, null); + } + + private static string? ExtractRenderMode(RenderTreeFrame[] frames, int componentFrameIndex, int endIndex) + { + for (var i = componentFrameIndex + 1; i < endIndex; i++) + { + if (frames[i].FrameType == RenderTreeFrameType.ComponentRenderMode) + { + return CacheSegment.GetRenderModeName(frames[i].ComponentRenderMode); + } + } + + return null; } 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 ac24baa2f7d5..c1b652fe6d58 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 CacheComponentStore? _cacheStore; private Task? _servicesInitializedTask; private HttpContext _httpContext = default!; // Always set at the start of an inbound call private ResourceAssetCollection? _resourceCollection; @@ -60,6 +61,7 @@ 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; diff --git a/src/Components/Endpoints/test/CacheComponentJsonTest.cs b/src/Components/Endpoints/test/CacheComponentJsonTest.cs new file mode 100644 index 000000000000..4bf017b8b436 --- /dev/null +++ b/src/Components/Endpoints/test/CacheComponentJsonTest.cs @@ -0,0 +1,288 @@ +// 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 CacheComponentJsonTest +{ + [Fact] + public void AddHtml_AddsHtmlSegment() + { + var json = new CacheComponentJson(); + + 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 CacheComponentJson(); + + json.AddHole(typeof(NotCacheComponent)); + + Assert.Equal(1, json.Count); + var segment = GetSegments(json)[0]; + Assert.Equal(CacheSegmentKind.Hole, segment.Kind); + Assert.Equal(typeof(NotCacheComponent), segment.ComponentType); + Assert.Null(segment.Html); + Assert.Null(segment.RenderModeName); + Assert.Null(segment.ComponentKey); + } + + [Fact] + public void AddHole_WithRenderModeAndKey() + { + var json = new CacheComponentJson(); + + json.AddHole(typeof(NotCacheComponent), "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 CacheComponentJson(); + + Assert.Throws(() => json.AddHtml(null!)); + } + + [Fact] + public void AddHole_ThrowsForNullType() + { + var json = new CacheComponentJson(); + + Assert.Throws(() => json.AddHole(null!)); + } + + [Fact] + public void Count_ReflectsNumberOfSegments() + { + var json = new CacheComponentJson(); + Assert.Equal(0, json.Count); + + json.AddHtml("

1

"); + Assert.Equal(1, json.Count); + + json.AddHole(typeof(NotCacheComponent)); + Assert.Equal(2, json.Count); + + json.AddHtml("

2

"); + Assert.Equal(3, json.Count); + } + + [Fact] + public void SerializeDeserialize_HtmlOnly() + { + var original = new CacheComponentJson(); + original.AddHtml("
cached
"); + original.AddHtml("

more

"); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.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_HoleOnly() + { + var original = new CacheComponentJson(); + original.AddHole(typeof(NotCacheComponent)); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.Deserialize(serialized); + + Assert.Equal(1, restored.Count); + var segment = GetSegments(restored)[0]; + Assert.Equal(CacheSegmentKind.Hole, segment.Kind); + Assert.Equal(typeof(NotCacheComponent), segment.ComponentType); + } + + [Fact] + public void SerializeDeserialize_MixedSegments() + { + var original = new CacheComponentJson(); + original.AddHtml("
cached
"); + original.AddHole(typeof(NotCacheComponent)); + original.AddHtml("
also cached
"); + original.AddHole(typeof(CacheComponent), "InteractiveWebAssembly", "key-1"); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.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(NotCacheComponent), 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(CacheComponent), segments[3].ComponentType); + Assert.Equal("InteractiveWebAssembly", segments[3].RenderModeName); + Assert.Equal("key-1", segments[3].ComponentKey); + } + + [Fact] + public void SerializeDeserialize_EmptySegments() + { + var original = new CacheComponentJson(); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.Deserialize(serialized); + + Assert.Equal(0, restored.Count); + } + + [Theory] + [InlineData("InteractiveServer")] + [InlineData("InteractiveWebAssembly")] + [InlineData("InteractiveAuto")] + public void SerializeDeserialize_PreservesRenderModes(string renderModeName) + { + var original = new CacheComponentJson(); + original.AddHole(typeof(NotCacheComponent), renderModeName); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.Deserialize(serialized); + + var segment = GetSegments(restored)[0]; + Assert.Equal(renderModeName, segment.RenderModeName); + } + + [Fact] + public void SerializeDeserialize_PreservesHtmlWithSpecialCharacters() + { + var html = "
Hello world & goodbye
"; + var original = new CacheComponentJson(); + original.AddHtml(html); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.Deserialize(serialized); + + Assert.Equal(html, GetSegments(restored)[0].Html); + } + + [Fact] + public void Deserialize_ThrowsForNull() + { + Assert.Throws(() => CacheComponentJson.Deserialize(null!)); + } + + [Fact] + public void Deserialize_ThrowsForInvalidJson() + { + Assert.ThrowsAny(() => CacheComponentJson.Deserialize("not valid json")); + } + + [Fact] + public void Deserialize_ThrowsForUnknownSegmentType() + { + var json = """[{"Type":"unknown","Content":"test"}]"""; + + var ex = Assert.Throws(() => CacheComponentJson.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(() => CacheComponentJson.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(() => CacheComponentJson.Deserialize(json)); + Assert.Contains("Could not resolve hole component type", ex.Message); + } + + [Fact] + public void ReconstructRenderMode_ReturnsCorrectModes() + { + Assert.Null(CacheSegment.CreateHole(typeof(NotCacheComponent)).ReconstructRenderMode()); + Assert.IsType(CacheSegment.CreateHole(typeof(NotCacheComponent), "InteractiveServer").ReconstructRenderMode()); + Assert.IsType(CacheSegment.CreateHole(typeof(NotCacheComponent), "InteractiveWebAssembly").ReconstructRenderMode()); + Assert.IsType(CacheSegment.CreateHole(typeof(NotCacheComponent), "InteractiveAuto").ReconstructRenderMode()); + } + + [Fact] + public void ReconstructRenderMode_ThrowsForUnknownMode() + { + var segment = CacheSegment.CreateHole(typeof(NotCacheComponent), "SomeFutureMode"); + + var ex = Assert.Throws(() => segment.ReconstructRenderMode()); + Assert.Contains("Unknown cached render mode", 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); + } + + [Fact] + public void RenderMode_RoundTrips_ThroughNameAndReconstruct() + { + var modes = new IComponentRenderMode[] { RenderMode.InteractiveServer, RenderMode.InteractiveWebAssembly, RenderMode.InteractiveAuto }; + + foreach (var mode in modes) + { + var name = CacheSegment.GetRenderModeName(mode); + var segment = CacheSegment.CreateHole(typeof(NotCacheComponent), name); + var reconstructed = segment.ReconstructRenderMode(); + + Assert.Equal(mode.GetType(), reconstructed!.GetType()); + } + } + + private static List GetSegments(CacheComponentJson 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/CacheComponentKeyResolverTest.cs b/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs new file mode 100644 index 000000000000..13f8954a5c26 --- /dev/null +++ b/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs @@ -0,0 +1,303 @@ +// 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 CacheComponentKeyResolverTest +{ + [Fact] + public void ComputeKey_IsDeterministic() + { + var component = CreateComponent(); + var httpContext = CreateHttpContext(); + + var key1 = CacheComponentKeyResolver.ComputeKey(component, httpContext); + var key2 = CacheComponentKeyResolver.ComputeKey(component, httpContext); + + Assert.Equal(key1, key2); + } + + [Fact] + public void ComputeKey_IsBase64EncodedSha256() + { + var component = CreateComponent(); + var httpContext = CreateHttpContext(); + + var key = CacheComponentKeyResolver.ComputeKey(component, httpContext); + + // SHA256 = 32 bytes -> Base64 = ceil(32/3)*4 = 44 chars (with padding) + Assert.Equal(44, key.Length); + Assert.True(key.EndsWith('=')); + } + + [Fact] + public void ComputeKey_WithoutChildContent_UsesClassName() + { + var component = CreateComponent(useDefaultChildContent: false); + var httpContext = CreateHttpContext(); + + var key = CacheComponentKeyResolver.ComputeKey(component, httpContext); + + Assert.NotNull(key); + Assert.NotEmpty(key); + } + + [Fact] + public void ComputeKey_DifferentChildContent_ProducesDifferentKeys() + { + var httpContext = CreateHttpContext(); + var component1 = CreateComponent(childContent: builder => builder.AddContent(0, "a")); + var component2 = CreateComponent(childContent: builder => builder.AddContent(0, "b")); + + var key1 = CacheComponentKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheComponentKeyResolver.ComputeKey(component2, httpContext); + + // Different lambda methods -> different declaring type/method name -> different keys + 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 = CacheComponentKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void ComputeKey_VaryByQuery_MultipleParams() + { + var component = CreateComponent(varyByQuery: "page, size"); + var ctx1 = CreateHttpContext(queryString: "?page=1&size=10"); + var ctx2 = CreateHttpContext(queryString: "?page=1&size=20"); + + var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, httpContext); + + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + CultureInfo.CurrentUICulture = new CultureInfo("fr-FR"); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + + Assert.NotEqual(key1, key2); + } + + private static RenderFragment DefaultChildContent => builder => builder.AddContent(0, "test"); + + private static CacheComponent 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, + bool useDefaultChildContent = true) + { + var component = new CacheComponent + { + ChildContent = childContent ?? (useDefaultChildContent ? DefaultChildContent : null), + CacheKey = cacheKey, + VaryByQuery = varyByQuery, + VaryByRoute = varyByRoute, + VaryByHeader = varyByHeader, + VaryByCookie = varyByCookie, + VaryByUser = varyByUser, + VaryByCulture = varyByCulture, + VaryBy = varyBy, + }; + 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/CacheComponentTextWriterTest.cs b/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs new file mode 100644 index 000000000000..82d78155511d --- /dev/null +++ b/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs @@ -0,0 +1,144 @@ +// 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 CacheComponentTextWriterTest +{ + [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); + } + + [Fact] + public void Write_ForwardsToInner_EvenDuringCapture() + { + var inner = new StringWriter(); + var writer = CreateWriter(inner); + + writer.StartCapture(); + writer.Write("both"); + writer.StopCapture(); + + Assert.Equal("both", inner.ToString()); + } + + private static CacheComponentTextWriter CreateWriter(TextWriter inner = null) + { + return new CacheComponentTextWriter(inner ?? new StringWriter(), new CacheComponentVaryBy()); + } + + private class FakeHoleComponent : IComponent + { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } +} diff --git a/src/Components/test/E2ETest/Tests/CacheComponentTest.cs b/src/Components/test/E2ETest/Tests/CacheComponentTest.cs new file mode 100644 index 000000000000..561e8bb0debb --- /dev/null +++ b/src/Components/test/E2ETest/Tests/CacheComponentTest.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +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 CacheComponentTest : ServerTestBase>> +{ + public CacheComponentTest( + 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 CacheComponentCachesData() + { + 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 CacheComponentDoesNotCacheDataWhenNotEnabled() + { + 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 CacheComponentCorrectlyCreatesHoles() + { + 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 NestedCacheComponentDoesNotExecuteOnOuterCacheHit() + { + 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 CacheComponentInLoopUsesVaryByForDistinctEntries() + { + 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; + } + + // 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); + } + } + + [Fact] + public void CacheComponentCachesLoopContent() + { + Navigate($"{ServerPathBase}/cache-component"); + var items = Browser.FindElement(By.Id("test-6")).FindElements(By.CssSelector(".loop-cached-item")); + Assert.Equal(3, items.Count); + + var firstRenderValues = new string[3]; + for (var i = 0; i < 3; i++) + { + firstRenderValues[i] = items[i].Text; + } + + // Second navigation — all loop items should come from cache + Navigate($"{ServerPathBase}/cache-component"); + for (var i = 0; i < 3; i++) + { + var index = i; + Browser.Equal(firstRenderValues[index], () => + Browser.FindElement(By.Id("test-6")) + .FindElements(By.CssSelector(".loop-cached-item"))[index].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..caa0675340df 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.CacheComponentTest; 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.CacheComponentStore") + ?? throw new InvalidOperationException("CacheComponentStore type not found. The internal type may have been renamed or moved."); + var store = context.RequestServices.GetService(storeType) + ?? throw new InvalidOperationException("CacheComponentStore is not registered in DI."); + var clearMethod = storeType.GetMethod("Clear") + ?? throw new InvalidOperationException("CacheComponentStore.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/CacheComponentTest/CacheComponentTest.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor new file mode 100644 index 000000000000..34e414b4335a --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor @@ -0,0 +1,71 @@ +@page "/cache-component" +@using Microsoft.AspNetCore.Components.Forms + +

CacheComponent

+ +
+ +

@DateTime.Now.ToString("o")

+ +

@DateTime.Now.ToString("o")

+
+
+

@DateTime.Now.ToString("o")

+
+ +
+ +

@DateTime.Now.ToString("o")

+ +

@DateTime.Now.ToString("o")

+
+
+

@DateTime.Now.ToString("o")

+
+ +
+ + + + + + +

@Message

+
+
+
+ +
+ +

@DateTime.Now.ToString("o")

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

@DateTime.Now.ToString("o")

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

Item @i: @DateTime.Now.ToString("o")

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

@DateTime.Now.ToString("o")

+ +@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); + } +} From 476c95b5c8d25873b4a4711f8cff782e287b4bb0 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Tue, 14 Apr 2026 16:58:54 +0200 Subject: [PATCH 2/6] Fixes --- .../src/CacheComponent/CacheComponent.cs | 38 +++-- .../src/CacheComponent/CacheComponentJson.cs | 53 +++--- .../CacheComponentKeyResolver.cs | 83 +++++---- .../src/CacheComponent/CacheStoreOptions.cs | 3 + .../MemoryCacheComponentStore.cs | 8 +- .../Endpoints/src/PublicAPI.Unshipped.txt | 2 + .../src/Rendering/CacheComponentTextWriter.cs | 13 +- .../EndpointHtmlRenderer.Streaming.cs | 94 ++--------- .../Endpoints/test/CacheComponentJsonTest.cs | 126 ++++++-------- .../test/CacheComponentKeyResolverTest.cs | 77 ++++----- .../test/CacheComponentRenderTest.cs | 140 ++++++++++++++++ .../test/CacheComponentTextWriterTest.cs | 13 -- .../Endpoints/test/IsHoleComponentTest.cs | 157 ++++++++++++++++++ .../test/E2ETest/Tests/CacheComponentTest.cs | 27 --- .../CacheComponentTest.razor | 9 - 15 files changed, 512 insertions(+), 331 deletions(-) create mode 100644 src/Components/Endpoints/test/CacheComponentRenderTest.cs create mode 100644 src/Components/Endpoints/test/IsHoleComponentTest.cs diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs index b55bd964edbb..f36a676ec824 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs @@ -5,15 +5,16 @@ 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 output of its child content during -/// server-side rendering (SSR). On cache hit, child components are not instantiated -/// or rendered, preventing unnecessary data fetching and computation. +/// 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 CacheComponent : ComponentBase { @@ -24,9 +25,8 @@ public sealed class CacheComponent : ComponentBase public RenderFragment? ChildContent { get; set; } /// - /// Gets or sets an explicit cache key for additional disambiguation. Only needed - /// when a reusable component uses internally and - /// multiple instances of that component appear on the same page. + /// Gets or sets an explicit cache key for disambiguation when multiple + /// instances share the same component ancestor. /// [Parameter] public string? CacheKey { get; set; } @@ -38,23 +38,29 @@ public sealed class CacheComponent : ComponentBase public bool Enabled { get; set; } = true; /// - /// Gets or sets the duration, from the time the cache entry was added, when it should be evicted. + /// Gets or sets how long after creation the cache entry should be evicted. /// [Parameter] public TimeSpan? ExpiresAfter { get; set; } /// - /// Gets or sets the exact the cache entry should be evicted. + /// Gets or sets the absolute when the cache entry should be evicted. /// [Parameter] public DateTimeOffset? ExpiresOn { get; set; } /// - /// Gets or sets the duration from last access that the cache entry should be evicted. + /// 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. /// @@ -97,10 +103,8 @@ public sealed class CacheComponent : ComponentBase [Parameter] public string? VaryBy { get; set; } - // Injected cache store — registered as singleton in DI. [Inject] internal CacheComponentStore? CacheStore { get; set; } - // HttpContext is cascaded by the SSR infrastructure. [CascadingParameter] internal HttpContext? HttpContext { get; set; } internal string? ResolvedCacheKey { get; private set; } @@ -158,10 +162,15 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } freshFrameSearchStart = ApplyFreshAttributes( builder, ref seq, freshFrames, freshFrameSearchStart, segment.ComponentType!); - var renderMode = segment.ReconstructRenderMode(); - if (renderMode is not null) + if (segment.RenderModeName is { } renderModeName) { - builder.AddComponentRenderMode(renderMode); + 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; @@ -207,7 +216,6 @@ private bool TryRestoreFromCache(out CacheComponentJson? cacheJson) try { cacheJson = CacheComponentJson.Deserialize(CachedData); - return cacheJson.Count > 0; } catch (Exception ex) diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs index cd424e4d6b7a..5875856482fb 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs @@ -19,7 +19,7 @@ public void AddHtml(string html) _segments.Add(CacheSegment.CreateHtml(html)); } - public void AddHole(Type componentType, string? renderModeName = null, string? componentKey = null) + public void AddHole(Type componentType, string? renderModeName = null, object? componentKey = null) { ArgumentNullException.ThrowIfNull(componentType); _segments.Add(CacheSegment.CreateHole(componentType, renderModeName, componentKey)); @@ -41,7 +41,8 @@ public string Serialize() Type = "hole", Content = segment.ComponentType!.AssemblyQualifiedName, RenderMode = segment.RenderModeName, - Key = segment.ComponentKey, + Key = SerializeKey(segment.ComponentKey), + KeyType = segment.ComponentKey?.GetType().FullName, }, _ => throw new InvalidOperationException($"Unknown segment kind: {segment.Kind}"), }; @@ -72,7 +73,7 @@ public static CacheComponentJson Deserialize(string json) { throw new InvalidOperationException($"Resolved type '{type.FullName}' is not a valid component type."); } - result.AddHole(type, entry.RenderMode, entry.Key); + result.AddHole(type, entry.RenderMode, DeserializeKey(entry.Key, entry.KeyType)); break; default: throw new InvalidOperationException($"Unknown cache segment type: '{entry.Type}'."); @@ -82,6 +83,29 @@ public static CacheComponentJson Deserialize(string json) 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"; @@ -91,6 +115,8 @@ internal sealed class JsonCacheSegment public string? RenderMode { get; set; } public string? Key { get; set; } + + public string? KeyType { get; set; } } [JsonSerializable(typeof(JsonCacheSegment[]))] @@ -105,9 +131,9 @@ internal readonly struct CacheSegment public string? Html { get; } public Type? ComponentType { get; } public string? RenderModeName { get; } - public string? ComponentKey { get; } + public object? ComponentKey { get; } - private CacheSegment(CacheSegmentKind kind, string? html, Type? componentType, string? renderModeName = null, string? componentKey = null) + private CacheSegment(CacheSegmentKind kind, string? html, Type? componentType, string? renderModeName = null, object? componentKey = null) { Kind = kind; Html = html; @@ -117,24 +143,9 @@ private CacheSegment(CacheSegmentKind kind, string? html, Type? componentType, s } public static CacheSegment CreateHtml(string html) => new(CacheSegmentKind.Html, html, componentType: null); - public static CacheSegment CreateHole(Type componentType, string? renderModeName = null, string? componentKey = null) + public static CacheSegment CreateHole(Type componentType, string? renderModeName = null, object? componentKey = null) => new(CacheSegmentKind.Hole, html: null, componentType, renderModeName, componentKey); - /// - /// Reconstructs the from the serialized name, or returns null if no render mode was cached. - /// - public IComponentRenderMode? ReconstructRenderMode() - { - return RenderModeName switch - { - null => null, - "InteractiveServer" => RenderMode.InteractiveServer, - "InteractiveWebAssembly" => RenderMode.InteractiveWebAssembly, - "InteractiveAuto" => RenderMode.InteractiveAuto, - _ => throw new InvalidOperationException($"Unknown cached render mode: '{RenderModeName}'."), - }; - } - internal static string? GetRenderModeName(IComponentRenderMode? renderMode) { return renderMode switch diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs index a7f96411a7fe..e5673d512def 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs +++ b/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs @@ -10,9 +10,12 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal static class CacheComponentKeyResolver { + private static readonly char[] _separator = [',']; + internal static string ComputeKey(CacheComponent cacheComponent, HttpContext httpContext) { var sb = new StringBuilder(); + var request = httpContext.Request; if (cacheComponent.ChildContent is { } childContent) { @@ -20,71 +23,67 @@ internal static string ComputeKey(CacheComponent cacheComponent, HttpContext htt .Append('.') .Append(childContent.Method.Name); } - else + + if (cacheComponent.CacheKey is { } cacheKey) { - sb.Append(nameof(CacheComponent)); + sb.Append("||").Append(cacheKey); } - if (cacheComponent.CacheKey is { } cacheKey) + if (cacheComponent.VaryBy is { } varyBy) { - sb.Append('.').Append(cacheKey); + sb.Append("||VaryBy||").Append(varyBy); } - AppendVaryByValues(sb, cacheComponent, httpContext); + AppendDelimitedValues(sb, "VaryByQuery", cacheComponent.VaryByQuery, name => request.Query[name]); + AppendDelimitedValues(sb, "VaryByRoute", cacheComponent.VaryByRoute, name => request.RouteValues[name]); + AppendDelimitedValues(sb, "VaryByHeader", cacheComponent.VaryByHeader, name => request.Headers[name]); + AppendDelimitedValues(sb, "VaryByCookie", cacheComponent.VaryByCookie, name => request.Cookies[name]); + + if (cacheComponent.VaryByUser is true) + { + sb.Append("||VaryByUser||").Append(httpContext.User.Identity?.Name); + } + + if (cacheComponent.VaryByCulture is true) + { + sb.Append("||VaryByCulture||") + .Append(CultureInfo.CurrentCulture.Name) + .Append("||") + .Append(CultureInfo.CurrentUICulture.Name); + } return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()))); } - private static void AppendVaryByValues(StringBuilder sb, CacheComponent cacheComponent, HttpContext httpContext) + private static void AppendDelimitedValues( + StringBuilder sb, + string collectionName, + string? commaSeparated, + Func valueAccessor) { - var request = httpContext.Request; - - if (cacheComponent.VaryByQuery is { } varyByQuery) + if (commaSeparated is null) { - foreach (var name in varyByQuery.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - sb.Append('.').Append(name).Append('=').Append(request.Query[name]); - } + return; } - if (cacheComponent.VaryByRoute is { } varyByRoute) + var names = commaSeparated.Split(_separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (names.Length == 0) { - foreach (var name in varyByRoute.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - sb.Append('.').Append(name).Append('=').Append(request.RouteValues[name]); - } + return; } - if (cacheComponent.VaryByHeader is { } varyByHeader) - { - foreach (var name in varyByHeader.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - sb.Append('.').Append(name).Append('=').Append(request.Headers[name]); - } - } + sb.Append("||").Append(collectionName).Append('('); - if (cacheComponent.VaryByCookie is { } varyByCookie) + for (var i = 0; i < names.Length; i++) { - foreach (var name in varyByCookie.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + if (i > 0) { - sb.Append('.').Append(name).Append('=').Append(request.Cookies[name]); + sb.Append("||"); } - } - if (cacheComponent.VaryByUser is true) - { - sb.Append(".user=").Append(httpContext.User.Identity?.Name); - } - - if (cacheComponent.VaryByCulture is true) - { - sb.Append(".culture=").Append(CultureInfo.CurrentCulture.Name) - .Append('.').Append(CultureInfo.CurrentUICulture.Name); + sb.Append(names[i]).Append("||").Append(valueAccessor(names[i])); } - if (cacheComponent.VaryBy is { } varyBy) - { - sb.Append('.').Append(varyBy); - } + sb.Append(')'); } } diff --git a/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs b/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs index dfc93c2f5cba..32ac38e61f72 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs +++ b/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.Caching.Memory; + namespace Microsoft.AspNetCore.Components.Endpoints; internal readonly struct CacheStoreOptions @@ -8,4 +10,5 @@ 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/CacheComponent/MemoryCacheComponentStore.cs b/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs index 912bb3698152..41dd533505e4 100644 --- a/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs +++ b/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs @@ -35,7 +35,8 @@ public override void Set(string key, string json, CacheStoreOptions options = de { entryOptions.SlidingExpiration = options.ExpiresSliding.Value; } - else if (options.ExpiresOn.HasValue) + + if (options.ExpiresOn.HasValue) { entryOptions.AbsoluteExpiration = options.ExpiresOn.Value; } @@ -44,6 +45,11 @@ public override void Set(string key, string json, CacheStoreOptions options = de entryOptions.AbsoluteExpirationRelativeToNow = options.ExpiresAfter ?? DefaultExpiration; } + if (options.Priority.HasValue) + { + entryOptions.Priority = options.Priority.Value; + } + _cache.Set(key, json, entryOptions); } diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index e4c258c25d51..9b23822aea8a 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -17,6 +17,8 @@ Microsoft.AspNetCore.Components.CacheComponent.ExpiresOn.get -> System.DateTimeO Microsoft.AspNetCore.Components.CacheComponent.ExpiresOn.set -> void Microsoft.AspNetCore.Components.CacheComponent.ExpiresSliding.get -> System.TimeSpan? Microsoft.AspNetCore.Components.CacheComponent.ExpiresSliding.set -> void +Microsoft.AspNetCore.Components.CacheComponent.Priority.get -> Microsoft.Extensions.Caching.Memory.CacheItemPriority? +Microsoft.AspNetCore.Components.CacheComponent.Priority.set -> void Microsoft.AspNetCore.Components.CacheComponent.VaryBy.get -> string? Microsoft.AspNetCore.Components.CacheComponent.VaryBy.set -> void Microsoft.AspNetCore.Components.CacheComponent.VaryByCookie.get -> string? diff --git a/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs b/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs index 2e6c50fc3db7..e3577bf99dd1 100644 --- a/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs +++ b/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs @@ -7,14 +7,14 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class CacheComponentTextWriter : TextWriter { - private readonly TextWriter _inner; + private readonly TextWriter _innerWriter; private readonly CacheComponentJson _segments = new(); private readonly StringBuilder _buffer = new(); private bool _capturing; public CacheComponentTextWriter(TextWriter inner, CacheComponentVaryBy varyBy) { - _inner = inner; + _innerWriter = inner; VaryBy = varyBy; } @@ -22,12 +22,11 @@ public CacheComponentTextWriter(TextWriter inner, CacheComponentVaryBy varyBy) public bool IsCapturing => _capturing; - public override Encoding Encoding => _inner.Encoding; + public override Encoding Encoding => _innerWriter.Encoding; public override void Write(char value) { - _inner.Write(value); - + _innerWriter.Write(value); if (_capturing) { _buffer.Append(value); @@ -36,7 +35,7 @@ public override void Write(char value) public override void Write(string? value) { - _inner.Write(value); + _innerWriter.Write(value); if (_capturing) { _buffer.Append(value); @@ -58,7 +57,7 @@ public void StartCapture() _capturing = true; } - public void CreateHole(Type componentType, string? renderModeName = null, string? componentKey = null) + public void CreateHole(Type componentType, string? renderModeName = null, object? componentKey = null) { _segments.AddHole(componentType, renderModeName, componentKey); } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index c6ffa9a70e6f..cde6562d20cb 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -6,7 +6,6 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -295,6 +294,7 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo ExpiresAfter = cacheComponent.ExpiresAfter, ExpiresOn = cacheComponent.ExpiresOn, ExpiresSliding = cacheComponent.ExpiresSliding, + Priority = cacheComponent.Priority, }); return; } @@ -302,15 +302,16 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo } var renderBoundaryMarkers = allowBoundaryMarkers && componentState.StreamRendering; - var captureWriter = output as CacheComponentTextWriter; var pausedCapture = false; if (captureWriter is not null && captureWriter.IsCapturing && (IsHoleComponent(componentType, captureWriter.VaryBy) || renderBoundaryMarkers)) { pausedCapture = true; captureWriter.PauseCapture(); - var (renderModeName, componentKey) = ExtractHoleMetadata(componentId, componentState); - captureWriter.CreateHole(componentType, renderModeName, componentKey); + var renderModeName = componentState.Component is SSRRenderModeBoundary boundary2 + ? CacheSegment.GetRenderModeName(boundary2.RenderMode) + : null; + captureWriter.CreateHole(componentType, renderModeName, sequenceAndKey.Key); } ComponentEndMarker? endMarkerOrNull = default; @@ -370,50 +371,24 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo } } - // Determines whether a component must be rendered as a "hole" (uncached placeholder) - // in the cache template. Hole components are excluded from cached HTML and re-rendered - // fresh on every request, even on cache hits. - private static bool IsHoleComponent(Type componentType, CacheComponentVaryBy varyBy) - { - // Security: AuthorizeView is a hole unless VaryByUser is set, to avoid - // serving cached auth state to the wrong user. - if (componentType == typeof(Authorization.AuthorizeView) && !varyBy.VaryByUser) - { - return true; - } - - // Form components are holes because they contain antiforgery tokens and - // user-specific state that must not be served from cache. - if (componentType == typeof(EditForm) - || componentType == typeof(ValidationSummary)) - { - return true; - } - - // InputBase and ValidationMessage are generic — check the hierarchy - if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(ValidationMessage<>)) - { - return true; - } - - if (IsInputBaseDescendant(componentType)) - { - return true; - } - - return componentType == typeof(AntiforgeryToken) + internal static bool IsHoleComponent(Type componentType, CacheComponentVaryBy varyBy) + => (typeof(Authorization.AuthorizeViewCore).IsAssignableFrom(componentType) && !varyBy.VaryByUser) + || componentType == typeof(Components.Forms.EditForm) + || componentType == typeof(Components.Forms.ValidationSummary) + || (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(Components.Forms.ValidationMessage<>)) + || IsInputBaseDescendant(componentType) + || componentType == typeof(Components.Forms.AntiforgeryToken) || componentType == typeof(NotCacheComponent) || componentType == typeof(SSRRenderModeBoundary) || componentType == typeof(Web.HeadOutlet) || componentType == typeof(Sections.SectionOutlet) || componentType == typeof(Sections.SectionContent); - } - private static bool IsInputBaseDescendant(Type componentType) + internal static bool IsInputBaseDescendant(Type componentType) { while (componentType is not null && componentType != typeof(object)) { - if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(InputBase<>)) + if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(Components.Forms.InputBase<>)) { return true; } @@ -422,47 +397,6 @@ private static bool IsInputBaseDescendant(Type componentType) return false; } - private (string? RenderModeName, string? ComponentKey) ExtractHoleMetadata(int componentId, EndpointComponentState componentState) - { - var parentState = componentState.ParentComponentState; - if (parentState is null) - { - return (null, null); - } - - var parentFrames = GetCurrentRenderTreeFrames(parentState.ComponentId); - var frames = parentFrames.Array; - var count = parentFrames.Count; - - for (var i = 0; i < count; i++) - { - ref var frame = ref frames[i]; - if (frame.FrameType == RenderTreeFrameType.Component && frame.ComponentId == componentId) - { - var endIndex = i + frame.ComponentSubtreeLength; - var renderModeName = ExtractRenderMode(frames, i, endIndex); - var componentKey = frame.ComponentKey as string; - - return (renderModeName, componentKey); - } - } - - return (null, null); - } - - private static string? ExtractRenderMode(RenderTreeFrame[] frames, int componentFrameIndex, int endIndex) - { - for (var i = componentFrameIndex + 1; i < endIndex; i++) - { - if (frames[i].FrameType == RenderTreeFrameType.ComponentRenderMode) - { - return CacheSegment.GetRenderModeName(frames[i].ComponentRenderMode); - } - } - - return null; - } - 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/test/CacheComponentJsonTest.cs b/src/Components/Endpoints/test/CacheComponentJsonTest.cs index 4bf017b8b436..0630d2d20812 100644 --- a/src/Components/Endpoints/test/CacheComponentJsonTest.cs +++ b/src/Components/Endpoints/test/CacheComponentJsonTest.cs @@ -65,22 +65,6 @@ public void AddHole_ThrowsForNullType() Assert.Throws(() => json.AddHole(null!)); } - [Fact] - public void Count_ReflectsNumberOfSegments() - { - var json = new CacheComponentJson(); - Assert.Equal(0, json.Count); - - json.AddHtml("

1

"); - Assert.Equal(1, json.Count); - - json.AddHole(typeof(NotCacheComponent)); - Assert.Equal(2, json.Count); - - json.AddHtml("

2

"); - Assert.Equal(3, json.Count); - } - [Fact] public void SerializeDeserialize_HtmlOnly() { @@ -99,21 +83,6 @@ public void SerializeDeserialize_HtmlOnly() Assert.Equal("

more

", segments[1].Html); } - [Fact] - public void SerializeDeserialize_HoleOnly() - { - var original = new CacheComponentJson(); - original.AddHole(typeof(NotCacheComponent)); - - var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); - - Assert.Equal(1, restored.Count); - var segment = GetSegments(restored)[0]; - Assert.Equal(CacheSegmentKind.Hole, segment.Kind); - Assert.Equal(typeof(NotCacheComponent), segment.ComponentType); - } - [Fact] public void SerializeDeserialize_MixedSegments() { @@ -157,33 +126,73 @@ public void SerializeDeserialize_EmptySegments() Assert.Equal(0, restored.Count); } - [Theory] - [InlineData("InteractiveServer")] - [InlineData("InteractiveWebAssembly")] - [InlineData("InteractiveAuto")] - public void SerializeDeserialize_PreservesRenderModes(string renderModeName) + [Fact] + public void SerializeDeserialize_PreservesHtmlWithSpecialCharacters() + { + var html = "
Hello world & goodbye
"; + var original = new CacheComponentJson(); + original.AddHtml(html); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.Deserialize(serialized); + + Assert.Equal(html, GetSegments(restored)[0].Html); + } + + [Fact] + public void SerializeDeserialize_PreservesIntKey() { var original = new CacheComponentJson(); - original.AddHole(typeof(NotCacheComponent), renderModeName); + original.AddHole(typeof(NotCacheComponent), componentKey: 42); var serialized = original.Serialize(); var restored = CacheComponentJson.Deserialize(serialized); var segment = GetSegments(restored)[0]; - Assert.Equal(renderModeName, segment.RenderModeName); + Assert.IsType(segment.ComponentKey); + Assert.Equal(42, segment.ComponentKey); } [Fact] - public void SerializeDeserialize_PreservesHtmlWithSpecialCharacters() + public void SerializeDeserialize_PreservesGuidKey() { - var html = "
Hello world & goodbye
"; + var guid = Guid.NewGuid(); var original = new CacheComponentJson(); - original.AddHtml(html); + original.AddHole(typeof(NotCacheComponent), componentKey: guid); var serialized = original.Serialize(); var restored = CacheComponentJson.Deserialize(serialized); - Assert.Equal(html, GetSegments(restored)[0].Html); + var segment = GetSegments(restored)[0]; + Assert.IsType(segment.ComponentKey); + Assert.Equal(guid, segment.ComponentKey); + } + + [Fact] + public void SerializeDeserialize_PreservesStringKey() + { + var original = new CacheComponentJson(); + original.AddHole(typeof(NotCacheComponent), componentKey: "my-key"); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.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 CacheComponentJson(); + original.AddHole(typeof(NotCacheComponent), componentKey: null); + + var serialized = original.Serialize(); + var restored = CacheComponentJson.Deserialize(serialized); + + var segment = GetSegments(restored)[0]; + Assert.Null(segment.ComponentKey); } [Fact] @@ -225,24 +234,6 @@ public void Deserialize_ThrowsForUnresolvableComponentType() Assert.Contains("Could not resolve hole component type", ex.Message); } - [Fact] - public void ReconstructRenderMode_ReturnsCorrectModes() - { - Assert.Null(CacheSegment.CreateHole(typeof(NotCacheComponent)).ReconstructRenderMode()); - Assert.IsType(CacheSegment.CreateHole(typeof(NotCacheComponent), "InteractiveServer").ReconstructRenderMode()); - Assert.IsType(CacheSegment.CreateHole(typeof(NotCacheComponent), "InteractiveWebAssembly").ReconstructRenderMode()); - Assert.IsType(CacheSegment.CreateHole(typeof(NotCacheComponent), "InteractiveAuto").ReconstructRenderMode()); - } - - [Fact] - public void ReconstructRenderMode_ThrowsForUnknownMode() - { - var segment = CacheSegment.CreateHole(typeof(NotCacheComponent), "SomeFutureMode"); - - var ex = Assert.Throws(() => segment.ReconstructRenderMode()); - Assert.Contains("Unknown cached render mode", ex.Message); - } - [Fact] public void GetRenderModeName_ReturnsCorrectNames() { @@ -259,21 +250,6 @@ public void GetRenderModeName_ThrowsForUnsupportedMode() Assert.Contains("Unsupported render mode type", ex.Message); } - [Fact] - public void RenderMode_RoundTrips_ThroughNameAndReconstruct() - { - var modes = new IComponentRenderMode[] { RenderMode.InteractiveServer, RenderMode.InteractiveWebAssembly, RenderMode.InteractiveAuto }; - - foreach (var mode in modes) - { - var name = CacheSegment.GetRenderModeName(mode); - var segment = CacheSegment.CreateHole(typeof(NotCacheComponent), name); - var reconstructed = segment.ReconstructRenderMode(); - - Assert.Equal(mode.GetType(), reconstructed!.GetType()); - } - } - private static List GetSegments(CacheComponentJson json) { var list = new List(); diff --git a/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs b/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs index 13f8954a5c26..4f4eb76cf66b 100644 --- a/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs +++ b/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs @@ -22,31 +22,6 @@ public void ComputeKey_IsDeterministic() Assert.Equal(key1, key2); } - [Fact] - public void ComputeKey_IsBase64EncodedSha256() - { - var component = CreateComponent(); - var httpContext = CreateHttpContext(); - - var key = CacheComponentKeyResolver.ComputeKey(component, httpContext); - - // SHA256 = 32 bytes -> Base64 = ceil(32/3)*4 = 44 chars (with padding) - Assert.Equal(44, key.Length); - Assert.True(key.EndsWith('=')); - } - - [Fact] - public void ComputeKey_WithoutChildContent_UsesClassName() - { - var component = CreateComponent(useDefaultChildContent: false); - var httpContext = CreateHttpContext(); - - var key = CacheComponentKeyResolver.ComputeKey(component, httpContext); - - Assert.NotNull(key); - Assert.NotEmpty(key); - } - [Fact] public void ComputeKey_DifferentChildContent_ProducesDifferentKeys() { @@ -87,19 +62,6 @@ public void ComputeKey_VaryByQuery_DifferentValues_ProducesDifferentKeys() Assert.NotEqual(key1, key2); } - [Fact] - public void ComputeKey_VaryByQuery_MultipleParams() - { - var component = CreateComponent(varyByQuery: "page, size"); - var ctx1 = CreateHttpContext(queryString: "?page=1&size=10"); - var ctx2 = CreateHttpContext(queryString: "?page=1&size=20"); - - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); - - Assert.NotEqual(key1, key2); - } - [Fact] public void ComputeKey_VaryByRoute_DifferentValues_ProducesDifferentKeys() { @@ -231,6 +193,40 @@ public void ComputeKey_MultipleVaryBy_AllContribute() 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 = CacheComponentKeyResolver.ComputeKey(componentWithQuery, ctxWithQueryUser); + var key2 = CacheComponentKeyResolver.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 = CacheComponentKeyResolver.ComputeKey(componentWithCookie, ctxWithCookie); + var key2 = CacheComponentKeyResolver.ComputeKey(componentWithHeader, ctxWithHeader); + + Assert.NotEqual(key1, key2); + } + private static RenderFragment DefaultChildContent => builder => builder.AddContent(0, "test"); private static CacheComponent CreateComponent( @@ -242,12 +238,11 @@ private static CacheComponent CreateComponent( string varyByCookie = null, bool? varyByUser = null, bool? varyByCulture = null, - string varyBy = null, - bool useDefaultChildContent = true) + string varyBy = null) { var component = new CacheComponent { - ChildContent = childContent ?? (useDefaultChildContent ? DefaultChildContent : null), + ChildContent = childContent ?? DefaultChildContent, CacheKey = cacheKey, VaryByQuery = varyByQuery, VaryByRoute = varyByRoute, diff --git a/src/Components/Endpoints/test/CacheComponentRenderTest.cs b/src/Components/Endpoints/test/CacheComponentRenderTest.cs new file mode 100644 index 000000000000..a71cb01555f6 --- /dev/null +++ b/src/Components/Endpoints/test/CacheComponentRenderTest.cs @@ -0,0 +1,140 @@ +// 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 CacheComponentRenderTest +{ + [Fact] + public async Task MissingDependencies_FallsBackToChildContent() + { + var component = new CacheComponent + { + 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 CacheComponent + { + 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 CacheComponent", 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(CacheComponent 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 : CacheComponentStore + { + public Dictionary Data { get; } = new(); + public string? ReturnForAnyKey { get; set; } + + public override string? Get(string key) + => ReturnForAnyKey ?? (Data.TryGetValue(key, out var value) ? value : null); + + public override void Set(string key, string json, CacheStoreOptions options = default) + => Data[key] = json; + } + + 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/CacheComponentTextWriterTest.cs b/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs index 82d78155511d..22ee75278577 100644 --- a/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs +++ b/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs @@ -118,19 +118,6 @@ public void Encoding_MatchesInnerWriter() Assert.Equal(inner.Encoding, writer.Encoding); } - [Fact] - public void Write_ForwardsToInner_EvenDuringCapture() - { - var inner = new StringWriter(); - var writer = CreateWriter(inner); - - writer.StartCapture(); - writer.Write("both"); - writer.StopCapture(); - - Assert.Equal("both", inner.ToString()); - } - private static CacheComponentTextWriter CreateWriter(TextWriter inner = null) { return new CacheComponentTextWriter(inner ?? new StringWriter(), new CacheComponentVaryBy()); diff --git a/src/Components/Endpoints/test/IsHoleComponentTest.cs b/src/Components/Endpoints/test/IsHoleComponentTest.cs new file mode 100644 index 000000000000..881541f10249 --- /dev/null +++ b/src/Components/Endpoints/test/IsHoleComponentTest.cs @@ -0,0 +1,157 @@ +// 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 CacheComponentVaryBy DefaultVaryBy = new(); + private static readonly CacheComponentVaryBy VaryByUser = new() { VaryByUser = true }; + + [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 NotCacheComponent_IsHole() + { + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(NotCacheComponent), 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)); + } + + [Fact] + public void IsInputBaseDescendant_ReturnsFalse_ForNonInputType() + { + Assert.False(EndpointHtmlRenderer.IsInputBaseDescendant(typeof(ComponentBase))); + } + + [Fact] + public void IsInputBaseDescendant_ReturnsTrue_ForDirectDescendant() + { + Assert.True(EndpointHtmlRenderer.IsInputBaseDescendant(typeof(InputText))); + } + + [Fact] + public void IsInputBaseDescendant_ReturnsTrue_ForIndirectDescendant() + { + Assert.True(EndpointHtmlRenderer.IsInputBaseDescendant(typeof(CustomInput))); + } + + private class PlainComponent : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder builder) { } + } + + private class CustomAuthorizeView : AuthorizeView { } + + private class CustomInput : InputText { } +} diff --git a/src/Components/test/E2ETest/Tests/CacheComponentTest.cs b/src/Components/test/E2ETest/Tests/CacheComponentTest.cs index 561e8bb0debb..6e1b972c60ce 100644 --- a/src/Components/test/E2ETest/Tests/CacheComponentTest.cs +++ b/src/Components/test/E2ETest/Tests/CacheComponentTest.cs @@ -1,10 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Globalization; -using System.Text; using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; @@ -116,30 +113,6 @@ public void CacheComponentInLoopUsesVaryByForDistinctEntries() } } - [Fact] - public void CacheComponentCachesLoopContent() - { - Navigate($"{ServerPathBase}/cache-component"); - var items = Browser.FindElement(By.Id("test-6")).FindElements(By.CssSelector(".loop-cached-item")); - Assert.Equal(3, items.Count); - - var firstRenderValues = new string[3]; - for (var i = 0; i < 3; i++) - { - firstRenderValues[i] = items[i].Text; - } - - // Second navigation — all loop items should come from cache - Navigate($"{ServerPathBase}/cache-component"); - for (var i = 0; i < 3; i++) - { - var index = i; - Browser.Equal(firstRenderValues[index], () => - Browser.FindElement(By.Id("test-6")) - .FindElements(By.CssSelector(".loop-cached-item"))[index].Text); - } - } - private int GetRenderCount() { Navigate($"{ServerPathBase}/cache-component/render-count"); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor index 34e414b4335a..b90f0c44d777 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor @@ -55,15 +55,6 @@ } -
- - @for (var i = 0; i < 3; i++) - { -

Item @i: @DateTime.Now.ToString("o")

- } -
-
- @code { [SupplyParameterFromForm(FormName = "editFormTest")] From bde38ec76c2f1d6d8b386e665910783fb810e326 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 16 Apr 2026 12:34:06 +0200 Subject: [PATCH 3/6] Improvements --- .../RazorComponentsServiceOptions.cs | 15 +++++++++++++-- ....AspNetCore.Components.Endpoints.Tests.csproj | 1 + .../test/E2ETest/Tests/CacheComponentTest.cs | 1 + .../CacheComponentTest/CacheComponentTest.razor | 16 ++++++++-------- .../InnerCachedComponent.razor | 2 +- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs index dd7234f6cf64..85c53ba92e0e 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs @@ -107,7 +107,18 @@ public TimeSpan TemporaryRedirectionUrlValidityDuration /// /// 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. + /// 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 CacheComponentSizeLimit { get; set; } = 100_000_000; + public long CacheComponentSizeLimit + { + get => _cacheComponentSizeLimit; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + _cacheComponentSizeLimit = value; + } + } + + private long _cacheComponentSizeLimit = 100_000_000; } 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/test/E2ETest/Tests/CacheComponentTest.cs b/src/Components/test/E2ETest/Tests/CacheComponentTest.cs index 6e1b972c60ce..471a8f93f4cd 100644 --- a/src/Components/test/E2ETest/Tests/CacheComponentTest.cs +++ b/src/Components/test/E2ETest/Tests/CacheComponentTest.cs @@ -100,6 +100,7 @@ public void CacheComponentInLoopUsesVaryByForDistinctEntries() { 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"); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor index b90f0c44d777..b6fd3d36e5a1 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor @@ -5,22 +5,22 @@
-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

@@ -37,7 +37,7 @@
-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

@@ -49,7 +49,7 @@ {
-

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor index cf7ac99d32b9..adaadb4b861a 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor @@ -1,4 +1,4 @@ -

@DateTime.Now.ToString("o")

+

@Guid.NewGuid()

@code { From 741aec23869d0a0edc82688db5988d07199418f2 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 22 Apr 2026 16:44:01 +0200 Subject: [PATCH 4/6] Feedback --- .../Authorization/src/AuthorizeViewCore.cs | 1 + .../src/CacheBoundaryPolicyAttribute.cs | 40 +++++ .../Components/src/CacheBoundaryVaryBy.cs | 48 ++++++ .../Components/src/PublicAPI.Unshipped.txt | 14 ++ .../Components/src/Sections/SectionContent.cs | 1 + .../Components/src/Sections/SectionOutlet.cs | 1 + .../CacheBoundary.cs} | 63 ++++++-- .../CacheBoundaryJson.cs} | 6 +- .../CacheBoundary/CacheBoundaryKeyResolver.cs | 151 ++++++++++++++++++ .../CacheBoundaryStore.cs} | 2 +- .../CacheStoreOptions.cs | 0 .../MemoryCacheBoundaryStore.cs} | 6 +- .../NotCacheBoundary.cs} | 3 +- .../CacheComponentKeyResolver.cs | 89 ----------- .../CacheComponent/CacheComponentVaryBy.cs | 14 -- ...orComponentsServiceCollectionExtensions.cs | 3 +- .../RazorComponentsServiceOptions.cs | 10 +- .../Endpoints/src/PublicAPI.Unshipped.txt | 72 ++++----- ...xtWriter.cs => CacheBoundaryTextWriter.cs} | 10 +- .../src/Rendering/EndpointComponentState.cs | 87 +++++++++- .../EndpointHtmlRenderer.Prerendering.cs | 3 + .../EndpointHtmlRenderer.Streaming.cs | 55 +++---- .../src/Rendering/EndpointHtmlRenderer.cs | 4 +- .../src/Rendering/SSRRenderModeBoundary.cs | 1 + ...ntJsonTest.cs => CacheBoundaryJsonTest.cs} | 78 ++++----- ...est.cs => CacheBoundaryKeyResolverTest.cs} | 77 ++++----- ...nderTest.cs => CacheBoundaryRenderTest.cs} | 12 +- ...Test.cs => CacheBoundaryTextWriterTest.cs} | 6 +- .../Endpoints/test/IsHoleComponentTest.cs | 26 +-- .../Web/src/Forms/AntiforgeryToken.cs | 1 + src/Components/Web/src/Forms/EditForm.cs | 1 + src/Components/Web/src/Forms/InputBase.cs | 1 + .../Web/src/Forms/ValidationMessage.cs | 1 + .../Web/src/Forms/ValidationSummary.cs | 1 + src/Components/Web/src/Head/HeadOutlet.cs | 1 + ...eComponentTest.cs => CacheBoundaryTest.cs} | 14 +- ...omponentEndpointsNoInteractivityStartup.cs | 10 +- .../CacheBoundaryTest.razor} | 40 ++--- .../InnerCachedComponent.razor | 2 +- 39 files changed, 602 insertions(+), 353 deletions(-) create mode 100644 src/Components/Components/src/CacheBoundaryPolicyAttribute.cs create mode 100644 src/Components/Components/src/CacheBoundaryVaryBy.cs rename src/Components/Endpoints/src/{CacheComponent/CacheComponent.cs => CacheBoundary/CacheBoundary.cs} (82%) rename src/Components/Endpoints/src/{CacheComponent/CacheComponentJson.cs => CacheBoundary/CacheBoundaryJson.cs} (97%) create mode 100644 src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs rename src/Components/Endpoints/src/{CacheComponent/CacheComponentStore.cs => CacheBoundary/CacheBoundaryStore.cs} (94%) rename src/Components/Endpoints/src/{CacheComponent => CacheBoundary}/CacheStoreOptions.cs (100%) rename src/Components/Endpoints/src/{CacheComponent/MemoryCacheComponentStore.cs => CacheBoundary/MemoryCacheBoundaryStore.cs} (87%) rename src/Components/Endpoints/src/{CacheComponent/NotCacheComponent.cs => CacheBoundary/NotCacheBoundary.cs} (89%) delete mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs delete mode 100644 src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs rename src/Components/Endpoints/src/Rendering/{CacheComponentTextWriter.cs => CacheBoundaryTextWriter.cs} (83%) rename src/Components/Endpoints/test/{CacheComponentJsonTest.cs => CacheBoundaryJsonTest.cs} (73%) rename src/Components/Endpoints/test/{CacheComponentKeyResolverTest.cs => CacheBoundaryKeyResolverTest.cs} (73%) rename src/Components/Endpoints/test/{CacheComponentRenderTest.cs => CacheBoundaryRenderTest.cs} (93%) rename src/Components/Endpoints/test/{CacheComponentTextWriterTest.cs => CacheBoundaryTextWriterTest.cs} (93%) rename src/Components/test/E2ETest/Tests/{CacheComponentTest.cs => CacheBoundaryTest.cs} (91%) rename src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/{CacheComponentTest/CacheComponentTest.razor => CacheBoundaryTest/CacheBoundaryTest.razor} (59%) rename src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/{CacheComponentTest => CacheBoundaryTest}/InnerCachedComponent.razor (87%) 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..2b05f2e74562 --- /dev/null +++ b/src/Components/Components/src/CacheBoundaryPolicyAttribute.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. + +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. +/// +/// +/// +/// // Always excluded from cache: +/// [CacheBoundaryPolicy(Excluded = true)] +/// public class MyDynamicComponent : ComponentBase { } +/// +/// // Excluded unless the cache boundary varies by user: +/// [CacheBoundaryPolicy(Excluded = true, VaryBy = CacheBoundaryVaryBy.User)] +/// public class MyAuthComponent : ComponentBase { } +/// +/// +[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/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 680bed59deaf..fc79b9f9304e 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 static Microsoft.AspNetCore.Components.NavigationManagerExtensions.GetUriWithHash(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! hash) -> string! Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.get -> bool Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.init -> void 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/CacheComponent/CacheComponent.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs similarity index 82% rename from src/Components/Endpoints/src/CacheComponent/CacheComponent.cs rename to src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs index f36a676ec824..681982e43881 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponent.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Components; /// server-side rendering (SSR). On cache hit, child components are not /// instantiated or rendered. /// -public sealed class CacheComponent : ComponentBase +public sealed class CacheBoundary : ComponentBase { /// /// Gets or sets the content to be cached. @@ -26,7 +26,7 @@ public sealed class CacheComponent : ComponentBase /// /// Gets or sets an explicit cache key for disambiguation when multiple - /// instances share the same component ancestor. + /// instances share the same component ancestor. /// [Parameter] public string? CacheKey { get; set; } @@ -103,29 +103,60 @@ public sealed class CacheComponent : ComponentBase [Parameter] public string? VaryBy { get; set; } - [Inject] internal CacheComponentStore? CacheStore { get; set; } + [Inject] internal CacheBoundaryStore? 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 CacheComponentVaryBy GetVaryByOptions() => new() + internal CacheBoundaryVaryBy GetVaryByOptions() { - VaryByQuery = VaryByQuery is not null, - VaryByRoute = VaryByRoute is not null, - VaryByHeader = VaryByHeader is not null, - VaryByCookie = VaryByCookie is not null, - VaryByUser = VaryByUser is true, - VaryByCulture = VaryByCulture is true, - }; + 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 = CacheComponentKeyResolver.ComputeKey(this, httpContext); + ResolvedCacheKey = CacheBoundaryKeyResolver.ComputeKey(this, httpContext); CachedData = CacheStore.Get(ResolvedCacheKey); } @@ -204,7 +235,7 @@ private static int ApplyFreshAttributes( return searchStart; } - private bool TryRestoreFromCache(out CacheComponentJson? cacheJson) + private bool TryRestoreFromCache(out CacheBoundaryJson? cacheJson) { cacheJson = null; @@ -215,14 +246,14 @@ private bool TryRestoreFromCache(out CacheComponentJson? cacheJson) try { - cacheJson = CacheComponentJson.Deserialize(CachedData); + cacheJson = CacheBoundaryJson.Deserialize(CachedData); return cacheJson.Count > 0; } catch (Exception ex) { HttpContext?.RequestServices.GetService() - ?.CreateLogger() - .LogWarning(ex, "Failed to restore CacheComponent from cached data. Falling back to fresh render."); + ?.CreateLogger() + .LogWarning(ex, "Failed to restore CacheBoundary from cached data. Falling back to fresh render."); return false; } } diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs similarity index 97% rename from src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs rename to src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs index 5875856482fb..e434a4aa1ba0 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -internal sealed partial class CacheComponentJson +internal sealed partial class CacheBoundaryJson { private readonly List _segments = []; @@ -51,14 +51,14 @@ public string Serialize() return JsonSerializer.Serialize(entries, CacheJsonContext.Default.JsonCacheSegmentArray); } - public static CacheComponentJson Deserialize(string json) + 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 CacheComponentJson(); + var result = new CacheBoundaryJson(); foreach (var entry in entries) { switch (entry.Type) diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs new file mode 100644 index 000000000000..e3a17cef7ae6 --- /dev/null +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.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 System.Buffers; +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) + { + Span hashOutput = stackalloc byte[SHA256.HashSizeInBytes]; + byte[]? pool = null; + try + { + Span buffer = stackalloc byte[1024]; + var pos = 0; + + // Tree-position key (computed at EndpointComponentState constructor time) + AppendUtf8(ref buffer, ref pool, ref pos, cacheBoundary.TreePositionKey); + + // User-provided CacheKey parameter + if (cacheBoundary.CacheKey is not null) + { + AppendUtf8(ref buffer, ref pool, ref pos, "||CacheKey||"); + AppendUtf8(ref buffer, ref pool, ref pos, cacheBoundary.CacheKey); + } + + // VaryBy dimensions + var request = httpContext.Request; + + if (cacheBoundary.VaryBy is { } varyBy) + { + AppendUtf8(ref buffer, ref pool, ref pos, "||VaryBy||"); + AppendUtf8(ref buffer, ref pool, ref pos, varyBy); + } + + if (cacheBoundary.VaryByQuery is not null) + { + AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByQuery", cacheBoundary.VaryByQuery, name => (string?)request.Query[name]); + } + + if (cacheBoundary.VaryByRoute is not null) + { + AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByRoute", cacheBoundary.VaryByRoute, name => request.RouteValues[name]?.ToString()); + } + + if (cacheBoundary.VaryByHeader is not null) + { + AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByHeader", cacheBoundary.VaryByHeader, name => (string?)request.Headers[name]); + } + + if (cacheBoundary.VaryByCookie is not null) + { + AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByCookie", cacheBoundary.VaryByCookie, name => request.Cookies[name]); + } + + if (cacheBoundary.VaryByUser is true) + { + AppendUtf8(ref buffer, ref pool, ref pos, "||VaryByUser||"); + AppendUtf8(ref buffer, ref pool, ref pos, httpContext.User.Identity?.Name); + } + + if (cacheBoundary.VaryByCulture is true) + { + AppendUtf8(ref buffer, ref pool, ref pos, "||VaryByCulture||"); + AppendUtf8(ref buffer, ref pool, ref pos, CultureInfo.CurrentCulture.Name); + AppendUtf8(ref buffer, ref pool, ref pos, "||"); + AppendUtf8(ref buffer, ref pool, ref pos, CultureInfo.CurrentUICulture.Name); + } + + var hashSucceeded = SHA256.TryHashData(buffer[..pos], hashOutput, out _); + Debug.Assert(hashSucceeded); + return Convert.ToBase64String(hashOutput); + } + finally + { + if (pool is not null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + } + } + + private static void AppendUtf8(ref Span buffer, ref byte[]? pool, ref int pos, string? value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + int written; + while (!Encoding.UTF8.TryGetBytes(value, buffer[pos..], out written)) + { + GrowBuffer(ref pool, ref buffer, value.Length * 4 + pos); + } + + pos += written; + } + + private static void AppendDelimitedValues( + ref Span buffer, ref byte[]? pool, ref int pos, + string collectionName, string commaSeparated, Func valueAccessor) + { + var names = commaSeparated.Split(_separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (names.Length == 0) + { + return; + } + + AppendUtf8(ref buffer, ref pool, ref pos, "||"); + AppendUtf8(ref buffer, ref pool, ref pos, collectionName); + AppendUtf8(ref buffer, ref pool, ref pos, "("); + + for (var i = 0; i < names.Length; i++) + { + if (i > 0) + { + AppendUtf8(ref buffer, ref pool, ref pos, "||"); + } + + AppendUtf8(ref buffer, ref pool, ref pos, names[i]); + AppendUtf8(ref buffer, ref pool, ref pos, "||"); + AppendUtf8(ref buffer, ref pool, ref pos, valueAccessor(names[i])); + } + + AppendUtf8(ref buffer, ref pool, ref pos, ")"); + } + + private static void GrowBuffer(ref byte[]? pool, ref Span buffer, int? size = null) + { + var newPool = pool is null + ? ArrayPool.Shared.Rent(size ?? 2048) + : ArrayPool.Shared.Rent(Math.Max(size ?? pool.Length * 2, pool.Length * 2)); + buffer.CopyTo(newPool); + if (pool is not null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + + pool = newPool; + buffer = newPool; + } +} diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs similarity index 94% rename from src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs rename to src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs index 5319fd97648e..686b8e6d6870 100644 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; /// /// Provides a store for caching rendered component output as a JSON template-with-holes representation. /// -internal abstract class CacheComponentStore : IDisposable +internal abstract class CacheBoundaryStore : IDisposable { protected static readonly TimeSpan DefaultExpiration = TimeSpan.FromSeconds(30); diff --git a/src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs b/src/Components/Endpoints/src/CacheBoundary/CacheStoreOptions.cs similarity index 100% rename from src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs rename to src/Components/Endpoints/src/CacheBoundary/CacheStoreOptions.cs diff --git a/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs similarity index 87% rename from src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs rename to src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs index 41dd533505e4..6e4d36a42422 100644 --- a/src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs +++ b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs @@ -6,15 +6,15 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -internal sealed class MemoryCacheComponentStore : CacheComponentStore +internal sealed class MemoryCacheBoundaryStore : CacheBoundaryStore { private readonly MemoryCache _cache; - public MemoryCacheComponentStore(IOptions options) + public MemoryCacheBoundaryStore(IOptions options) { _cache = new MemoryCache(new MemoryCacheOptions { - SizeLimit = options.Value.CacheComponentSizeLimit, + SizeLimit = options.Value.CacheBoundarySizeLimit, }); } diff --git a/src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs b/src/Components/Endpoints/src/CacheBoundary/NotCacheBoundary.cs similarity index 89% rename from src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs rename to src/Components/Endpoints/src/CacheBoundary/NotCacheBoundary.cs index c418222fd037..422d5244f9d0 100644 --- a/src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs +++ b/src/Components/Endpoints/src/CacheBoundary/NotCacheBoundary.cs @@ -10,7 +10,8 @@ namespace Microsoft.AspNetCore.Components; /// of its child content. This is useful for opt-out scenarios when a parent component /// enables caching but certain child content should be excluded. /// -public sealed class NotCacheComponent : ComponentBase +[CacheBoundaryPolicy(Excluded = true)] +public sealed class NotCacheBoundary : ComponentBase { /// /// Gets or sets the content not to be cached. diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs deleted file mode 100644 index e5673d512def..000000000000 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Globalization; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Http; - -namespace Microsoft.AspNetCore.Components.Endpoints; - -internal static class CacheComponentKeyResolver -{ - private static readonly char[] _separator = [',']; - - internal static string ComputeKey(CacheComponent cacheComponent, HttpContext httpContext) - { - var sb = new StringBuilder(); - var request = httpContext.Request; - - if (cacheComponent.ChildContent is { } childContent) - { - sb.Append(childContent.Method.DeclaringType?.FullName) - .Append('.') - .Append(childContent.Method.Name); - } - - if (cacheComponent.CacheKey is { } cacheKey) - { - sb.Append("||").Append(cacheKey); - } - - if (cacheComponent.VaryBy is { } varyBy) - { - sb.Append("||VaryBy||").Append(varyBy); - } - - AppendDelimitedValues(sb, "VaryByQuery", cacheComponent.VaryByQuery, name => request.Query[name]); - AppendDelimitedValues(sb, "VaryByRoute", cacheComponent.VaryByRoute, name => request.RouteValues[name]); - AppendDelimitedValues(sb, "VaryByHeader", cacheComponent.VaryByHeader, name => request.Headers[name]); - AppendDelimitedValues(sb, "VaryByCookie", cacheComponent.VaryByCookie, name => request.Cookies[name]); - - if (cacheComponent.VaryByUser is true) - { - sb.Append("||VaryByUser||").Append(httpContext.User.Identity?.Name); - } - - if (cacheComponent.VaryByCulture is true) - { - sb.Append("||VaryByCulture||") - .Append(CultureInfo.CurrentCulture.Name) - .Append("||") - .Append(CultureInfo.CurrentUICulture.Name); - } - - return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()))); - } - - private static void AppendDelimitedValues( - StringBuilder sb, - string collectionName, - string? commaSeparated, - Func valueAccessor) - { - if (commaSeparated is null) - { - return; - } - - var names = commaSeparated.Split(_separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (names.Length == 0) - { - return; - } - - sb.Append("||").Append(collectionName).Append('('); - - for (var i = 0; i < names.Length; i++) - { - if (i > 0) - { - sb.Append("||"); - } - - sb.Append(names[i]).Append("||").Append(valueAccessor(names[i])); - } - - sb.Append(')'); - } -} diff --git a/src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs b/src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs deleted file mode 100644 index 9f5bf8c5f8ec..000000000000 --- a/src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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 CacheComponentVaryBy -{ - public bool VaryByQuery { get; set; } - public bool VaryByRoute { get; set; } - public bool VaryByHeader { get; set; } - public bool VaryByCookie { get; set; } - public bool VaryByUser { get; set; } - public bool VaryByCulture { get; set; } -} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 24ed304b0cdd..3425347effb0 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -75,8 +75,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddScoped(); services.AddTempData(); - services.TryAddSingleton(sp => - new MemoryCacheComponentStore(sp.GetRequiredService>())); + services.TryAddSingleton(); services.TryAddScoped(); RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveWebAssembly); diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs index 85c53ba92e0e..cb3b22d48bdc 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs @@ -105,20 +105,20 @@ public TimeSpan TemporaryRedirectionUrlValidityDuration public TempDataProviderType TempDataProviderType { get; set; } = TempDataProviderType.Cookie; /// - /// Gets or sets the maximum size, in bytes, of the memory cache used by + /// 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 CacheComponentSizeLimit + public long CacheBoundarySizeLimit { - get => _cacheComponentSizeLimit; + get => _CacheBoundarySizeLimit; set { ArgumentOutOfRangeException.ThrowIfNegative(value); - _cacheComponentSizeLimit = value; + _CacheBoundarySizeLimit = value; } } - private long _cacheComponentSizeLimit = 100_000_000; + private long _CacheBoundarySizeLimit = 100_000_000; } diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index 9b23822aea8a..feeb08719a4b 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,42 +1,42 @@ #nullable enable Microsoft.AspNetCore.Components.Endpoints.BasePath Microsoft.AspNetCore.Components.Endpoints.BasePath.BasePath() -> void -Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.CacheComponentSizeLimit.get -> long -Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.CacheComponentSizeLimit.set -> void -Microsoft.AspNetCore.Components.CacheComponent -Microsoft.AspNetCore.Components.CacheComponent.CacheComponent() -> void -Microsoft.AspNetCore.Components.CacheComponent.CacheKey.get -> string? -Microsoft.AspNetCore.Components.CacheComponent.CacheKey.set -> void -Microsoft.AspNetCore.Components.CacheComponent.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? -Microsoft.AspNetCore.Components.CacheComponent.ChildContent.set -> void -Microsoft.AspNetCore.Components.CacheComponent.Enabled.get -> bool -Microsoft.AspNetCore.Components.CacheComponent.Enabled.set -> void -Microsoft.AspNetCore.Components.CacheComponent.ExpiresAfter.get -> System.TimeSpan? -Microsoft.AspNetCore.Components.CacheComponent.ExpiresAfter.set -> void -Microsoft.AspNetCore.Components.CacheComponent.ExpiresOn.get -> System.DateTimeOffset? -Microsoft.AspNetCore.Components.CacheComponent.ExpiresOn.set -> void -Microsoft.AspNetCore.Components.CacheComponent.ExpiresSliding.get -> System.TimeSpan? -Microsoft.AspNetCore.Components.CacheComponent.ExpiresSliding.set -> void -Microsoft.AspNetCore.Components.CacheComponent.Priority.get -> Microsoft.Extensions.Caching.Memory.CacheItemPriority? -Microsoft.AspNetCore.Components.CacheComponent.Priority.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryBy.get -> string? -Microsoft.AspNetCore.Components.CacheComponent.VaryBy.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryByCookie.get -> string? -Microsoft.AspNetCore.Components.CacheComponent.VaryByCookie.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryByCulture.get -> bool? -Microsoft.AspNetCore.Components.CacheComponent.VaryByCulture.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryByHeader.get -> string? -Microsoft.AspNetCore.Components.CacheComponent.VaryByHeader.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryByQuery.get -> string? -Microsoft.AspNetCore.Components.CacheComponent.VaryByQuery.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryByRoute.get -> string? -Microsoft.AspNetCore.Components.CacheComponent.VaryByRoute.set -> void -Microsoft.AspNetCore.Components.CacheComponent.VaryByUser.get -> bool? -Microsoft.AspNetCore.Components.CacheComponent.VaryByUser.set -> void -Microsoft.AspNetCore.Components.NotCacheComponent -Microsoft.AspNetCore.Components.NotCacheComponent.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? -Microsoft.AspNetCore.Components.NotCacheComponent.ChildContent.set -> void -Microsoft.AspNetCore.Components.NotCacheComponent.NotCacheComponent() -> 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/CacheComponentTextWriter.cs b/src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs similarity index 83% rename from src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs rename to src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs index e3577bf99dd1..9d102370d56c 100644 --- a/src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs +++ b/src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs @@ -5,20 +5,20 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -internal sealed class CacheComponentTextWriter : TextWriter +internal sealed class CacheBoundaryTextWriter : TextWriter { private readonly TextWriter _innerWriter; - private readonly CacheComponentJson _segments = new(); + private readonly CacheBoundaryJson _segments = new(); private readonly StringBuilder _buffer = new(); private bool _capturing; - public CacheComponentTextWriter(TextWriter inner, CacheComponentVaryBy varyBy) + public CacheBoundaryTextWriter(TextWriter inner, CacheBoundaryVaryBy varyBy) { _innerWriter = inner; VaryBy = varyBy; } - public CacheComponentVaryBy VaryBy { get; set; } + public CacheBoundaryVaryBy VaryBy { get; set; } public bool IsCapturing => _capturing; @@ -62,7 +62,7 @@ public void CreateHole(Type componentType, string? renderModeName = null, object _segments.AddHole(componentType, renderModeName, componentKey); } - public CacheComponentJson StopCapture() + public CacheBoundaryJson StopCapture() { _capturing = false; diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index 6c03d0585f2f..2b03a93090f3 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -2,8 +2,11 @@ // 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 System.Security.Cryptography; +using System.Text; using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; @@ -16,12 +19,15 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class EndpointComponentState : ComponentState { private static readonly ConcurrentDictionary _streamRenderingAttributeByComponentType = new(); + private static readonly ConcurrentDictionary<(string, string?), string> _treePositionKeyCache = new(); + private static readonly string _cacheBoundaryTypeName = typeof(CacheBoundary).FullName!; static EndpointComponentState() { if (HotReloadManager.Default.MetadataUpdateSupported) { HotReloadManager.Default.OnDeltaApplied += _streamRenderingAttributeByComponentType.Clear; + HotReloadManager.Default.OnDeltaApplied += _treePositionKeyCache.Clear; } } @@ -43,6 +49,16 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com var parentEndpointComponentState = (EndpointComponentState?)LogicalParentComponentState; StreamRendering = parentEndpointComponentState?.StreamRendering ?? false; } + + if (component is CacheBoundary cacheBoundary) + { + var ancestorTypeName = parentComponentState?.Component?.GetType().FullName ?? ""; + cacheBoundary.TreePositionKeyFactory = () => + { + var (componentKey, sequence) = GetComponentKeyAndSequence(); + return ComputeTreePositionKey(ancestorTypeName, componentKey, sequence); + }; + } } public bool StreamRendering { get; } @@ -63,8 +79,77 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com return base.GetComponentKey(); } + private (object? Key, int Sequence) GetComponentKeyAndSequence() + { + if (ParentComponentState is not { } parentState) + { + return (null, 0); + } + + var frames = _renderer.GetRenderTreeFrames(parentState.ComponentId); + for (var i = 0; i < frames.Count; i++) + { + ref var currentFrame = ref frames.Array[i]; + if (currentFrame.FrameType == RenderTreeFrameType.Component && + ReferenceEquals(Component, currentFrame.Component)) + { + return (currentFrame.ComponentKey, currentFrame.Sequence); + } + } + + return (null, 0); + } + /// /// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection. /// - public static void UpdateApplication(Type[]? _) => _streamRenderingAttributeByComponentType.Clear(); + public static void UpdateApplication(Type[]? _) + { + _streamRenderingAttributeByComponentType.Clear(); + _treePositionKeyCache.Clear(); + } + + private static string ComputeTreePositionKey(string ancestorTypeName, object? componentKey, int sequence) + { + var keyString = FormatSerializableKey(componentKey); + var seqString = sequence.ToString(CultureInfo.InvariantCulture); + + if (keyString is null) + { + return _treePositionKeyCache.GetOrAdd((ancestorTypeName, seqString), static parts => + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes( + string.Concat(parts.Item1, ".", _cacheBoundaryTypeName, ".", parts.Item2))))); + } + + return _treePositionKeyCache.GetOrAdd((ancestorTypeName, string.Concat(seqString, ".", keyString)), static parts => + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes( + string.Concat(parts.Item1, ".", _cacheBoundaryTypeName, ".", parts.Item2))))); + } + + private static string? FormatSerializableKey(object? key) + { + if (key is null) + { + return null; + } + + var keyType = key.GetType(); + var isSerializable = Type.GetTypeCode(keyType) != TypeCode.Object + || keyType == typeof(Guid) + || keyType == typeof(DateTimeOffset) + || keyType == typeof(DateOnly) + || keyType == typeof(TimeOnly); + + if (!isSerializable) + { + return null; + } + + return key switch + { + IFormattable formattable => formattable.ToString("", CultureInfo.InvariantCulture), + IConvertible convertible => convertible.ToString(CultureInfo.InvariantCulture), + _ => key.ToString(), + }; + } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 0276cdfd1515..ccf4b616a3c6 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -283,6 +283,9 @@ internal static ServerComponentInvocationSequence GetOrCreateInvocationId(HttpCo return (ServerComponentInvocationSequence)result!; } + internal ArrayRange GetRenderTreeFrames(int componentId) + => GetCurrentRenderTreeFrames(componentId); + internal (int sequence, object? key) GetSequenceAndKey(ComponentState boundaryComponentState) { if (boundaryComponentState is null || boundaryComponentState.Component is not SSRRenderModeBoundary boundary) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index cde6562d20cb..3ff848383948 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; @@ -270,14 +272,12 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo _visitedComponentIdsInCurrentStreamingBatch?.Add(componentId); var componentState = (EndpointComponentState)GetComponentState(componentId); - var componentType = componentState.Component.GetType(); - if (componentType == typeof(CacheComponent)) + if (componentState.Component is CacheBoundary cacheBoundary) { - var cacheComponent = (CacheComponent)componentState.Component; - if (cacheComponent.Enabled && cacheComponent.ResolvedCacheKey is { } cacheKey) + if (cacheBoundary.Enabled && cacheBoundary.ResolvedCacheKey is { } cacheKey) { - if (cacheComponent.CachedData is not null) + if (cacheBoundary.CachedData is not null) { base.WriteComponentHtml(componentId, output); return; @@ -285,16 +285,16 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo if (_cacheStore is not null) { - var cacheCaptureWriter = new CacheComponentTextWriter(output, cacheComponent.GetVaryByOptions()); + 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 = cacheComponent.ExpiresAfter, - ExpiresOn = cacheComponent.ExpiresOn, - ExpiresSliding = cacheComponent.ExpiresSliding, - Priority = cacheComponent.Priority, + ExpiresAfter = cacheBoundary.ExpiresAfter, + ExpiresOn = cacheBoundary.ExpiresOn, + ExpiresSliding = cacheBoundary.ExpiresSliding, + Priority = cacheBoundary.Priority, }); return; } @@ -302,16 +302,16 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo } var renderBoundaryMarkers = allowBoundaryMarkers && componentState.StreamRendering; - var captureWriter = output as CacheComponentTextWriter; + var captureWriter = output as CacheBoundaryTextWriter; var pausedCapture = false; - if (captureWriter is not null && captureWriter.IsCapturing && (IsHoleComponent(componentType, captureWriter.VaryBy) || renderBoundaryMarkers)) + 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(componentType, renderModeName, sequenceAndKey.Key); + captureWriter.CreateHole(componentState.Component.GetType(), renderModeName, sequenceAndKey.Key); } ComponentEndMarker? endMarkerOrNull = default; @@ -371,30 +371,17 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo } } - internal static bool IsHoleComponent(Type componentType, CacheComponentVaryBy varyBy) - => (typeof(Authorization.AuthorizeViewCore).IsAssignableFrom(componentType) && !varyBy.VaryByUser) - || componentType == typeof(Components.Forms.EditForm) - || componentType == typeof(Components.Forms.ValidationSummary) - || (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(Components.Forms.ValidationMessage<>)) - || IsInputBaseDescendant(componentType) - || componentType == typeof(Components.Forms.AntiforgeryToken) - || componentType == typeof(NotCacheComponent) - || componentType == typeof(SSRRenderModeBoundary) - || componentType == typeof(Web.HeadOutlet) - || componentType == typeof(Sections.SectionOutlet) - || componentType == typeof(Sections.SectionContent); - - internal static bool IsInputBaseDescendant(Type componentType) + private static readonly ConcurrentDictionary _cachedCacheExclusions = new(); + + internal static bool IsHoleComponent(Type componentType, CacheBoundaryVaryBy varyBy) { - while (componentType is not null && componentType != typeof(object)) + if (!_cachedCacheExclusions.TryGetValue(componentType, out var attr)) { - if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(Components.Forms.InputBase<>)) - { - return true; - } - componentType = componentType.BaseType!; + attr = componentType.GetCustomAttribute(inherit: true); + _cachedCacheExclusions.TryAdd(componentType, attr); } - return false; + + 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 c1b652fe6d58..11eb8c26063b 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -40,7 +40,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer { private readonly IServiceProvider _services; private readonly RazorComponentsServiceOptions _options; - private readonly CacheComponentStore? _cacheStore; + private readonly CacheBoundaryStore? _cacheStore; private Task? _servicesInitializedTask; private HttpContext _httpContext = default!; // Always set at the start of an inbound call private ResourceAssetCollection? _resourceCollection; @@ -61,7 +61,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log _services = serviceProvider; _options = serviceProvider.GetRequiredService>().Value; _logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer"); - _cacheStore = serviceProvider.GetService(); + _cacheStore = serviceProvider.GetService(); } internal HttpContext? HttpContext => _httpContext; 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/CacheComponentJsonTest.cs b/src/Components/Endpoints/test/CacheBoundaryJsonTest.cs similarity index 73% rename from src/Components/Endpoints/test/CacheComponentJsonTest.cs rename to src/Components/Endpoints/test/CacheBoundaryJsonTest.cs index 0630d2d20812..3caab83e7aa9 100644 --- a/src/Components/Endpoints/test/CacheComponentJsonTest.cs +++ b/src/Components/Endpoints/test/CacheBoundaryJsonTest.cs @@ -5,12 +5,12 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -public class CacheComponentJsonTest +public class CacheBoundaryJsonTest { [Fact] public void AddHtml_AddsHtmlSegment() { - var json = new CacheComponentJson(); + var json = new CacheBoundaryJson(); json.AddHtml("

hello

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

more

"); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); Assert.Equal(2, restored.Count); var segments = GetSegments(restored); @@ -86,14 +86,14 @@ public void SerializeDeserialize_HtmlOnly() [Fact] public void SerializeDeserialize_MixedSegments() { - var original = new CacheComponentJson(); + var original = new CacheBoundaryJson(); original.AddHtml("
cached
"); - original.AddHole(typeof(NotCacheComponent)); + original.AddHole(typeof(NotCacheBoundary)); original.AddHtml("
also cached
"); - original.AddHole(typeof(CacheComponent), "InteractiveWebAssembly", "key-1"); + original.AddHole(typeof(CacheBoundary), "InteractiveWebAssembly", "key-1"); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); Assert.Equal(4, restored.Count); var segments = GetSegments(restored); @@ -102,7 +102,7 @@ public void SerializeDeserialize_MixedSegments() Assert.Equal("
cached
", segments[0].Html); Assert.Equal(CacheSegmentKind.Hole, segments[1].Kind); - Assert.Equal(typeof(NotCacheComponent), segments[1].ComponentType); + Assert.Equal(typeof(NotCacheBoundary), segments[1].ComponentType); Assert.Null(segments[1].RenderModeName); Assert.Null(segments[1].ComponentKey); @@ -110,7 +110,7 @@ public void SerializeDeserialize_MixedSegments() Assert.Equal("
also cached
", segments[2].Html); Assert.Equal(CacheSegmentKind.Hole, segments[3].Kind); - Assert.Equal(typeof(CacheComponent), segments[3].ComponentType); + Assert.Equal(typeof(CacheBoundary), segments[3].ComponentType); Assert.Equal("InteractiveWebAssembly", segments[3].RenderModeName); Assert.Equal("key-1", segments[3].ComponentKey); } @@ -118,10 +118,10 @@ public void SerializeDeserialize_MixedSegments() [Fact] public void SerializeDeserialize_EmptySegments() { - var original = new CacheComponentJson(); + var original = new CacheBoundaryJson(); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); Assert.Equal(0, restored.Count); } @@ -130,11 +130,11 @@ public void SerializeDeserialize_EmptySegments() public void SerializeDeserialize_PreservesHtmlWithSpecialCharacters() { var html = "
Hello world & goodbye
"; - var original = new CacheComponentJson(); + var original = new CacheBoundaryJson(); original.AddHtml(html); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); Assert.Equal(html, GetSegments(restored)[0].Html); } @@ -142,11 +142,11 @@ public void SerializeDeserialize_PreservesHtmlWithSpecialCharacters() [Fact] public void SerializeDeserialize_PreservesIntKey() { - var original = new CacheComponentJson(); - original.AddHole(typeof(NotCacheComponent), componentKey: 42); + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: 42); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); var segment = GetSegments(restored)[0]; Assert.IsType(segment.ComponentKey); @@ -157,11 +157,11 @@ public void SerializeDeserialize_PreservesIntKey() public void SerializeDeserialize_PreservesGuidKey() { var guid = Guid.NewGuid(); - var original = new CacheComponentJson(); - original.AddHole(typeof(NotCacheComponent), componentKey: guid); + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: guid); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); var segment = GetSegments(restored)[0]; Assert.IsType(segment.ComponentKey); @@ -171,11 +171,11 @@ public void SerializeDeserialize_PreservesGuidKey() [Fact] public void SerializeDeserialize_PreservesStringKey() { - var original = new CacheComponentJson(); - original.AddHole(typeof(NotCacheComponent), componentKey: "my-key"); + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: "my-key"); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); var segment = GetSegments(restored)[0]; Assert.IsType(segment.ComponentKey); @@ -185,11 +185,11 @@ public void SerializeDeserialize_PreservesStringKey() [Fact] public void SerializeDeserialize_PreservesNullKey() { - var original = new CacheComponentJson(); - original.AddHole(typeof(NotCacheComponent), componentKey: null); + var original = new CacheBoundaryJson(); + original.AddHole(typeof(NotCacheBoundary), componentKey: null); var serialized = original.Serialize(); - var restored = CacheComponentJson.Deserialize(serialized); + var restored = CacheBoundaryJson.Deserialize(serialized); var segment = GetSegments(restored)[0]; Assert.Null(segment.ComponentKey); @@ -198,13 +198,13 @@ public void SerializeDeserialize_PreservesNullKey() [Fact] public void Deserialize_ThrowsForNull() { - Assert.Throws(() => CacheComponentJson.Deserialize(null!)); + Assert.Throws(() => CacheBoundaryJson.Deserialize(null!)); } [Fact] public void Deserialize_ThrowsForInvalidJson() { - Assert.ThrowsAny(() => CacheComponentJson.Deserialize("not valid json")); + Assert.ThrowsAny(() => CacheBoundaryJson.Deserialize("not valid json")); } [Fact] @@ -212,7 +212,7 @@ public void Deserialize_ThrowsForUnknownSegmentType() { var json = """[{"Type":"unknown","Content":"test"}]"""; - var ex = Assert.Throws(() => CacheComponentJson.Deserialize(json)); + var ex = Assert.Throws(() => CacheBoundaryJson.Deserialize(json)); Assert.Contains("Unknown cache segment type", ex.Message); } @@ -221,7 +221,7 @@ public void Deserialize_ThrowsForHoleMissingComponentType() { var json = """[{"Type":"hole","Content":null}]"""; - var ex = Assert.Throws(() => CacheComponentJson.Deserialize(json)); + var ex = Assert.Throws(() => CacheBoundaryJson.Deserialize(json)); Assert.Contains("missing component type", ex.Message); } @@ -230,7 +230,7 @@ public void Deserialize_ThrowsForUnresolvableComponentType() { var json = """[{"Type":"hole","Content":"Some.Fake.Type, FakeAssembly"}]"""; - var ex = Assert.Throws(() => CacheComponentJson.Deserialize(json)); + var ex = Assert.Throws(() => CacheBoundaryJson.Deserialize(json)); Assert.Contains("Could not resolve hole component type", ex.Message); } @@ -250,7 +250,7 @@ public void GetRenderModeName_ThrowsForUnsupportedMode() Assert.Contains("Unsupported render mode type", ex.Message); } - private static List GetSegments(CacheComponentJson json) + private static List GetSegments(CacheBoundaryJson json) { var list = new List(); foreach (var segment in json) diff --git a/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs b/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs similarity index 73% rename from src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs rename to src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs index 4f4eb76cf66b..6400b9f60a03 100644 --- a/src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs +++ b/src/Components/Endpoints/test/CacheBoundaryKeyResolverTest.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -public class CacheComponentKeyResolverTest +public class CacheBoundaryKeyResolverTest { [Fact] public void ComputeKey_IsDeterministic() @@ -16,23 +16,22 @@ public void ComputeKey_IsDeterministic() var component = CreateComponent(); var httpContext = CreateHttpContext(); - var key1 = CacheComponentKeyResolver.ComputeKey(component, httpContext); - var key2 = CacheComponentKeyResolver.ComputeKey(component, httpContext); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); Assert.Equal(key1, key2); } [Fact] - public void ComputeKey_DifferentChildContent_ProducesDifferentKeys() + public void ComputeKey_DifferentTreePosition_ProducesDifferentKeys() { var httpContext = CreateHttpContext(); - var component1 = CreateComponent(childContent: builder => builder.AddContent(0, "a")); - var component2 = CreateComponent(childContent: builder => builder.AddContent(0, "b")); + var component1 = CreateComponent(treePositionKey: "ParentA.CacheBoundary"); + var component2 = CreateComponent(treePositionKey: "ParentB.CacheBoundary"); - var key1 = CacheComponentKeyResolver.ComputeKey(component1, httpContext); - var key2 = CacheComponentKeyResolver.ComputeKey(component2, httpContext); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component2, httpContext); - // Different lambda methods -> different declaring type/method name -> different keys Assert.NotEqual(key1, key2); } @@ -43,8 +42,8 @@ public void ComputeKey_CacheKey_ChangesOutput() var component1 = CreateComponent(cacheKey: "v1"); var component2 = CreateComponent(cacheKey: "v2"); - var key1 = CacheComponentKeyResolver.ComputeKey(component1, httpContext); - var key2 = CacheComponentKeyResolver.ComputeKey(component2, httpContext); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component2, httpContext); Assert.NotEqual(key1, key2); } @@ -56,8 +55,8 @@ public void ComputeKey_VaryByQuery_DifferentValues_ProducesDifferentKeys() var ctx1 = CreateHttpContext(queryString: "?page=1"); var ctx2 = CreateHttpContext(queryString: "?page=2"); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.NotEqual(key1, key2); } @@ -69,8 +68,8 @@ public void ComputeKey_VaryByRoute_DifferentValues_ProducesDifferentKeys() var ctx1 = CreateHttpContext(routeValues: new RouteValueDictionary { ["id"] = "1" }); var ctx2 = CreateHttpContext(routeValues: new RouteValueDictionary { ["id"] = "2" }); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.NotEqual(key1, key2); } @@ -82,8 +81,8 @@ public void ComputeKey_VaryByHeader_DifferentValues_ProducesDifferentKeys() var ctx1 = CreateHttpContext(headers: new Dictionary { ["Accept-Language"] = "en-US" }); var ctx2 = CreateHttpContext(headers: new Dictionary { ["Accept-Language"] = "fr-FR" }); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.NotEqual(key1, key2); } @@ -95,8 +94,8 @@ public void ComputeKey_VaryByCookie_DifferentValues_ProducesDifferentKeys() var ctx1 = CreateHttpContext(cookieHeader: "session=abc"); var ctx2 = CreateHttpContext(cookieHeader: "session=xyz"); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.NotEqual(key1, key2); } @@ -108,8 +107,8 @@ public void ComputeKey_VaryByUser_DifferentUsers_ProducesDifferentKeys() var ctx1 = CreateHttpContext(userName: "alice"); var ctx2 = CreateHttpContext(userName: "bob"); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.NotEqual(key1, key2); } @@ -121,8 +120,8 @@ public void ComputeKey_VaryByUser_Disabled_SameKeyRegardlessOfUser() var ctx1 = CreateHttpContext(userName: "alice"); var ctx2 = CreateHttpContext(userName: "bob"); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.Equal(key1, key2); } @@ -139,11 +138,11 @@ public void ComputeKey_VaryByCulture_DifferentCultures_ProducesDifferentKeys() { CultureInfo.CurrentCulture = new CultureInfo("en-US"); CultureInfo.CurrentUICulture = new CultureInfo("en-US"); - var key1 = CacheComponentKeyResolver.ComputeKey(component, httpContext); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); CultureInfo.CurrentUICulture = new CultureInfo("fr-FR"); - var key2 = CacheComponentKeyResolver.ComputeKey(component, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, httpContext); Assert.NotEqual(key1, key2); } @@ -161,8 +160,8 @@ public void ComputeKey_VaryBy_CustomString_ChangesKey() var component1 = CreateComponent(varyBy: "dark-theme"); var component2 = CreateComponent(varyBy: "light-theme"); - var key1 = CacheComponentKeyResolver.ComputeKey(component1, httpContext); - var key2 = CacheComponentKeyResolver.ComputeKey(component2, httpContext); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component1, httpContext); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component2, httpContext); Assert.NotEqual(key1, key2); } @@ -174,8 +173,8 @@ public void ComputeKey_NoVaryBy_SameKeyForDifferentRequests() var ctx1 = CreateHttpContext(queryString: "?page=1"); var ctx2 = CreateHttpContext(queryString: "?page=2"); - var key1 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.Equal(key1, key2); } @@ -187,8 +186,8 @@ public void ComputeKey_MultipleVaryBy_AllContribute() 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 = CacheComponentKeyResolver.ComputeKey(component, ctx1); - var key2 = CacheComponentKeyResolver.ComputeKey(component, ctx2); + var key1 = CacheBoundaryKeyResolver.ComputeKey(component, ctx1); + var key2 = CacheBoundaryKeyResolver.ComputeKey(component, ctx2); Assert.NotEqual(key1, key2); } @@ -204,8 +203,8 @@ public void ComputeKey_DifferentVaryByDimensions_DoNotCollide() var componentWithUser = CreateComponent(varyByUser: true); var ctxWithAuthUser = CreateHttpContext(userName: "alice"); - var key1 = CacheComponentKeyResolver.ComputeKey(componentWithQuery, ctxWithQueryUser); - var key2 = CacheComponentKeyResolver.ComputeKey(componentWithUser, ctxWithAuthUser); + var key1 = CacheBoundaryKeyResolver.ComputeKey(componentWithQuery, ctxWithQueryUser); + var key2 = CacheBoundaryKeyResolver.ComputeKey(componentWithUser, ctxWithAuthUser); Assert.NotEqual(key1, key2); } @@ -221,15 +220,15 @@ public void ComputeKey_DifferentCollectionDimensions_DoNotCollide() var componentWithHeader = CreateComponent(varyByHeader: "lang"); var ctxWithHeader = CreateHttpContext(headers: new Dictionary { ["lang"] = "en" }); - var key1 = CacheComponentKeyResolver.ComputeKey(componentWithCookie, ctxWithCookie); - var key2 = CacheComponentKeyResolver.ComputeKey(componentWithHeader, ctxWithHeader); + 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 CacheComponent CreateComponent( + private static CacheBoundary CreateComponent( RenderFragment childContent = null, string cacheKey = null, string varyByQuery = null, @@ -238,9 +237,10 @@ private static CacheComponent CreateComponent( string varyByCookie = null, bool? varyByUser = null, bool? varyByCulture = null, - string varyBy = null) + string varyBy = null, + string treePositionKey = "DefaultParent.CacheBoundary") { - var component = new CacheComponent + var component = new CacheBoundary { ChildContent = childContent ?? DefaultChildContent, CacheKey = cacheKey, @@ -251,6 +251,7 @@ private static CacheComponent CreateComponent( VaryByUser = varyByUser, VaryByCulture = varyByCulture, VaryBy = varyBy, + TreePositionKeyFactory = () => treePositionKey, }; return component; } diff --git a/src/Components/Endpoints/test/CacheComponentRenderTest.cs b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs similarity index 93% rename from src/Components/Endpoints/test/CacheComponentRenderTest.cs rename to src/Components/Endpoints/test/CacheBoundaryRenderTest.cs index a71cb01555f6..7dad916547cd 100644 --- a/src/Components/Endpoints/test/CacheComponentRenderTest.cs +++ b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs @@ -10,12 +10,12 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -public class CacheComponentRenderTest +public class CacheBoundaryRenderTest { [Fact] public async Task MissingDependencies_FallsBackToChildContent() { - var component = new CacheComponent + var component = new CacheBoundary { ChildContent = builder => builder.AddContent(0, "hello"), }; @@ -34,7 +34,7 @@ public async Task DeserializationFailure_FallsBackToChildContent_AndLogsWarning( var store = new TestCacheStore { ReturnForAnyKey = "NOT VALID JSON {{{" }; - var component = new CacheComponent + var component = new CacheBoundary { ChildContent = builder => builder.AddContent(0, "fallback"), CacheStore = store, @@ -46,7 +46,7 @@ public async Task DeserializationFailure_FallsBackToChildContent_AndLogsWarning( AssertContainsText(frames, "fallback"); var entry = Assert.Single(testLogger.Entries); Assert.Equal(LogLevel.Warning, entry.Level); - Assert.Contains("Failed to restore CacheComponent", entry.Message); + Assert.Contains("Failed to restore CacheBoundary", entry.Message); Assert.NotNull(entry.Exception); } @@ -61,7 +61,7 @@ private static HttpContext CreateHttpContext() return context; } - private static async Task> RenderComponent(CacheComponent component) + private static async Task> RenderComponent(CacheBoundary component) { var renderer = new TestRenderer(); var id = renderer.AssignRootComponentId(component); @@ -84,7 +84,7 @@ private static void AssertContainsText(ArrayRange frames, strin Assert.Fail($"Expected to find text frame '{expectedText}' but it was not present."); } - private sealed class TestCacheStore : CacheComponentStore + private sealed class TestCacheStore : CacheBoundaryStore { public Dictionary Data { get; } = new(); public string? ReturnForAnyKey { get; set; } diff --git a/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs b/src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs similarity index 93% rename from src/Components/Endpoints/test/CacheComponentTextWriterTest.cs rename to src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs index 22ee75278577..9d8625abe3d6 100644 --- a/src/Components/Endpoints/test/CacheComponentTextWriterTest.cs +++ b/src/Components/Endpoints/test/CacheBoundaryTextWriterTest.cs @@ -3,7 +3,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -public class CacheComponentTextWriterTest +public class CacheBoundaryTextWriterTest { [Fact] public void Write_AlwaysForwardsToInner() @@ -118,9 +118,9 @@ public void Encoding_MatchesInnerWriter() Assert.Equal(inner.Encoding, writer.Encoding); } - private static CacheComponentTextWriter CreateWriter(TextWriter inner = null) + private static CacheBoundaryTextWriter CreateWriter(TextWriter inner = null) { - return new CacheComponentTextWriter(inner ?? new StringWriter(), new CacheComponentVaryBy()); + return new CacheBoundaryTextWriter(inner ?? new StringWriter(), CacheBoundaryVaryBy.None); } private class FakeHoleComponent : IComponent diff --git a/src/Components/Endpoints/test/IsHoleComponentTest.cs b/src/Components/Endpoints/test/IsHoleComponentTest.cs index 881541f10249..cc818acdadf9 100644 --- a/src/Components/Endpoints/test/IsHoleComponentTest.cs +++ b/src/Components/Endpoints/test/IsHoleComponentTest.cs @@ -11,8 +11,8 @@ namespace Microsoft.AspNetCore.Components.Endpoints; public class IsHoleComponentTest { - private static readonly CacheComponentVaryBy DefaultVaryBy = new(); - private static readonly CacheComponentVaryBy VaryByUser = new() { VaryByUser = true }; + private static readonly CacheBoundaryVaryBy DefaultVaryBy = CacheBoundaryVaryBy.None; + private static readonly CacheBoundaryVaryBy VaryByUser = CacheBoundaryVaryBy.User; [Fact] public void EditForm_IsHole() @@ -57,9 +57,9 @@ public void AntiforgeryToken_IsHole() } [Fact] - public void NotCacheComponent_IsHole() + public void NotCacheBoundary_IsHole() { - Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(NotCacheComponent), DefaultVaryBy)); + Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(NotCacheBoundary), DefaultVaryBy)); } [Fact] @@ -128,24 +128,6 @@ public void CustomInputBaseDescendant_IsHole() Assert.True(EndpointHtmlRenderer.IsHoleComponent(typeof(CustomInput), DefaultVaryBy)); } - [Fact] - public void IsInputBaseDescendant_ReturnsFalse_ForNonInputType() - { - Assert.False(EndpointHtmlRenderer.IsInputBaseDescendant(typeof(ComponentBase))); - } - - [Fact] - public void IsInputBaseDescendant_ReturnsTrue_ForDirectDescendant() - { - Assert.True(EndpointHtmlRenderer.IsInputBaseDescendant(typeof(InputText))); - } - - [Fact] - public void IsInputBaseDescendant_ReturnsTrue_ForIndirectDescendant() - { - Assert.True(EndpointHtmlRenderer.IsInputBaseDescendant(typeof(CustomInput))); - } - private class PlainComponent : ComponentBase { protected override void BuildRenderTree(RenderTreeBuilder builder) { } 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/CacheComponentTest.cs b/src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs similarity index 91% rename from src/Components/test/E2ETest/Tests/CacheComponentTest.cs rename to src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs index 471a8f93f4cd..98b6454ed9e1 100644 --- a/src/Components/test/E2ETest/Tests/CacheComponentTest.cs +++ b/src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs @@ -12,9 +12,9 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests; -public class CacheComponentTest : ServerTestBase>> +public class CacheBoundaryTest : ServerTestBase>> { - public CacheComponentTest( + public CacheBoundaryTest( BrowserFixture browserFixture, BasicTestAppServerSiteFixture> serverFixture, ITestOutputHelper output) @@ -31,7 +31,7 @@ protected override void InitializeAsyncCore() } [Fact] - public void CacheComponentCachesData() + public void CacheBoundaryCachesData() { Navigate($"{ServerPathBase}/cache-component"); var testElement = Browser.FindElement(By.Id("test-1")); @@ -44,7 +44,7 @@ public void CacheComponentCachesData() } [Fact] - public void CacheComponentDoesNotCacheDataWhenNotEnabled() + public void CacheBoundaryDoesNotCacheDataWhenNotEnabled() { Navigate($"{ServerPathBase}/cache-component"); var testElement = Browser.FindElement(By.Id("test-2")); @@ -57,7 +57,7 @@ public void CacheComponentDoesNotCacheDataWhenNotEnabled() } [Fact] - public void CacheComponentCorrectlyCreatesHoles() + public void CacheBoundaryCorrectlyCreatesHoles() { Navigate($"{ServerPathBase}/cache-component"); var testElement = Browser.FindElement(By.Id("test-3")); @@ -74,7 +74,7 @@ public void CacheComponentCorrectlyCreatesHoles() } [Fact] - public void NestedCacheComponentDoesNotExecuteOnOuterCacheHit() + public void NestedCacheBoundaryDoesNotExecuteOnOuterCacheHit() { Navigate($"{ServerPathBase}/cache-component"); Browser.Exists(By.Id("inner-cached")); @@ -88,7 +88,7 @@ public void NestedCacheComponentDoesNotExecuteOnOuterCacheHit() } [Fact] - public void CacheComponentInLoopUsesVaryByForDistinctEntries() + public void CacheBoundaryInLoopUsesVaryByForDistinctEntries() { Navigate($"{ServerPathBase}/cache-component"); var loopItems = Browser.FindElement(By.Id("test-5")).FindElements(By.CssSelector(".loop-item")); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index caa0675340df..b6837827efe8 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -6,7 +6,7 @@ using System.Security.Claims; using System.Web; using Components.TestServer.RazorComponents; -using Components.TestServer.RazorComponents.Pages.CacheComponentTest; +using Components.TestServer.RazorComponents.Pages.CacheBoundaryTest; using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; using Microsoft.AspNetCore.Components.Endpoints; @@ -144,12 +144,12 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen endpoints.MapGet("cache-component/clear", (HttpContext context) => { var storeType = typeof(RazorComponentsServiceOptions).Assembly - .GetType("Microsoft.AspNetCore.Components.Endpoints.CacheComponentStore") - ?? throw new InvalidOperationException("CacheComponentStore type not found. The internal type may have been renamed or moved."); + .GetType("Microsoft.AspNetCore.Components.Endpoints.CacheBoundaryStore") + ?? throw new InvalidOperationException("CacheBoundaryStore type not found. The internal type may have been renamed or moved."); var store = context.RequestServices.GetService(storeType) - ?? throw new InvalidOperationException("CacheComponentStore is not registered in DI."); + ?? throw new InvalidOperationException("CacheBoundaryStore is not registered in DI."); var clearMethod = storeType.GetMethod("Clear") - ?? throw new InvalidOperationException("CacheComponentStore.Clear() method not found."); + ?? throw new InvalidOperationException("CacheBoundaryStore.Clear() method not found."); clearMethod.Invoke(store, null); InnerCachedComponent.ResetRenderCount(); }); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor similarity index 59% rename from src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor rename to src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor index b6fd3d36e5a1..35685d44cb40 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/CacheBoundaryTest.razor @@ -1,56 +1,56 @@ -@page "/cache-component" +@page "/cache-component" @using Microsoft.AspNetCore.Components.Forms -

CacheComponent

+

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()

-
+
}
diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/InnerCachedComponent.razor similarity index 87% rename from src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor rename to src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/InnerCachedComponent.razor index adaadb4b861a..42b0e254429e 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/InnerCachedComponent.razor @@ -1,4 +1,4 @@ -

@Guid.NewGuid()

+

@Guid.NewGuid()

@code { From b55dcb20644d8e972bddd09d4ca06eb9339e5316 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Fri, 24 Apr 2026 10:41:10 +0200 Subject: [PATCH 5/6] Feedback fix --- .../src/CacheBoundaryPolicyAttribute.cs | 11 -- .../Microsoft.AspNetCore.Components.csproj | 1 + ...PersistentStateValueProviderKeyResolver.cs | 31 +--- .../src/CacheBoundary/CacheBoundary.cs | 2 +- .../src/CacheBoundary/CacheBoundaryJson.cs | 7 +- .../CacheBoundary/CacheBoundaryKeyResolver.cs | 151 +++++++----------- .../src/CacheBoundary/CacheBoundaryStore.cs | 17 +- .../CacheBoundary/MemoryCacheBoundaryStore.cs | 12 +- ...orComponentsServiceCollectionExtensions.cs | 2 +- .../RazorComponentsServiceOptions.cs | 1 + ...oft.AspNetCore.Components.Endpoints.csproj | 1 + .../src/Rendering/EndpointComponentState.cs | 92 +++-------- .../EndpointHtmlRenderer.Prerendering.cs | 3 - .../src/Rendering/EndpointHtmlRenderer.cs | 7 +- .../Endpoints/test/CacheBoundaryRenderTest.cs | 8 +- .../Shared/src/ComponentKeyHelper.cs | 40 +++++ ...omponentEndpointsNoInteractivityStartup.cs | 8 +- 17 files changed, 154 insertions(+), 240 deletions(-) create mode 100644 src/Components/Shared/src/ComponentKeyHelper.cs diff --git a/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs b/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs index 2b05f2e74562..b87432d76d6e 100644 --- a/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs +++ b/src/Components/Components/src/CacheBoundaryPolicyAttribute.cs @@ -10,17 +10,6 @@ namespace Microsoft.AspNetCore.Components; /// Optionally, set to lift the exclusion when the cache boundary /// varies by the specified dimensions. /// -/// -/// -/// // Always excluded from cache: -/// [CacheBoundaryPolicy(Excluded = true)] -/// public class MyDynamicComponent : ComponentBase { } -/// -/// // Excluded unless the cache boundary varies by user: -/// [CacheBoundaryPolicy(Excluded = true, VaryBy = CacheBoundaryVaryBy.User)] -/// public class MyAuthComponent : ComponentBase { } -/// -/// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] public sealed class CacheBoundaryPolicyAttribute : Attribute { 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/Endpoints/src/CacheBoundary/CacheBoundary.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs index 681982e43881..bdbff762bac7 100644 --- a/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs @@ -103,7 +103,7 @@ public sealed class CacheBoundary : ComponentBase [Parameter] public string? VaryBy { get; set; } - [Inject] internal CacheBoundaryStore? CacheStore { get; set; } + [Inject] internal ICacheBoundaryStore? CacheStore { get; set; } [CascadingParameter] internal HttpContext? HttpContext { get; set; } diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs index e434a4aa1ba0..3b3614d4914e 100644 --- a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs @@ -47,7 +47,6 @@ public string Serialize() _ => throw new InvalidOperationException($"Unknown segment kind: {segment.Kind}"), }; } - return JsonSerializer.Serialize(entries, CacheJsonContext.Default.JsonCacheSegmentArray); } @@ -89,7 +88,6 @@ public static CacheBoundaryJson Deserialize(string json) { return null; } - return JsonSerializer.Serialize(key); } @@ -99,10 +97,7 @@ public static CacheBoundaryJson Deserialize(string json) { return null; } - - var type = Type.GetType(keyType) - ?? throw new InvalidOperationException($"Could not resolve key type: '{keyType}'."); - + var type = Type.GetType(keyType) ?? throw new InvalidOperationException($"Could not resolve key type: '{keyType}'."); return JsonSerializer.Deserialize(keyValue, type); } diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs index e3a17cef7ae6..feaeda99dfd9 100644 --- a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Buffers; using System.Diagnostics; using System.Globalization; using System.Security.Cryptography; @@ -16,136 +15,96 @@ internal static class CacheBoundaryKeyResolver internal static string ComputeKey(CacheBoundary cacheBoundary, HttpContext httpContext) { - Span hashOutput = stackalloc byte[SHA256.HashSizeInBytes]; - byte[]? pool = null; - try - { - Span buffer = stackalloc byte[1024]; - var pos = 0; - - // Tree-position key (computed at EndpointComponentState constructor time) - AppendUtf8(ref buffer, ref pool, ref pos, cacheBoundary.TreePositionKey); - - // User-provided CacheKey parameter - if (cacheBoundary.CacheKey is not null) - { - AppendUtf8(ref buffer, ref pool, ref pos, "||CacheKey||"); - AppendUtf8(ref buffer, ref pool, ref pos, cacheBoundary.CacheKey); - } + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendString(hash, cacheBoundary.TreePositionKey); - // VaryBy dimensions - var request = httpContext.Request; - - if (cacheBoundary.VaryBy is { } varyBy) - { - AppendUtf8(ref buffer, ref pool, ref pos, "||VaryBy||"); - AppendUtf8(ref buffer, ref pool, ref pos, varyBy); - } - - if (cacheBoundary.VaryByQuery is not null) - { - AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByQuery", cacheBoundary.VaryByQuery, name => (string?)request.Query[name]); - } - - if (cacheBoundary.VaryByRoute is not null) - { - AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByRoute", cacheBoundary.VaryByRoute, name => request.RouteValues[name]?.ToString()); - } - - if (cacheBoundary.VaryByHeader is not null) - { - AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByHeader", cacheBoundary.VaryByHeader, name => (string?)request.Headers[name]); - } + if (cacheBoundary.CacheKey is not null) + { + AppendString(hash, "||CacheKey||"); + AppendString(hash, cacheBoundary.CacheKey); + } - if (cacheBoundary.VaryByCookie is not null) - { - AppendDelimitedValues(ref buffer, ref pool, ref pos, "VaryByCookie", cacheBoundary.VaryByCookie, name => request.Cookies[name]); - } + var request = httpContext.Request; + if (cacheBoundary.VaryBy is { } varyBy) + { + AppendString(hash, "||VaryBy||"); + AppendString(hash, varyBy); + } - if (cacheBoundary.VaryByUser is true) - { - AppendUtf8(ref buffer, ref pool, ref pos, "||VaryByUser||"); - AppendUtf8(ref buffer, ref pool, ref pos, httpContext.User.Identity?.Name); - } + if (cacheBoundary.VaryByQuery is not null) + { + AppendDelimitedValues(hash, "VaryByQuery", cacheBoundary.VaryByQuery, name => (string?)request.Query[name]); + } - if (cacheBoundary.VaryByCulture is true) - { - AppendUtf8(ref buffer, ref pool, ref pos, "||VaryByCulture||"); - AppendUtf8(ref buffer, ref pool, ref pos, CultureInfo.CurrentCulture.Name); - AppendUtf8(ref buffer, ref pool, ref pos, "||"); - AppendUtf8(ref buffer, ref pool, ref pos, CultureInfo.CurrentUICulture.Name); - } + if (cacheBoundary.VaryByRoute is not null) + { + AppendDelimitedValues(hash, "VaryByRoute", cacheBoundary.VaryByRoute, name => request.RouteValues[name]?.ToString()); + } - var hashSucceeded = SHA256.TryHashData(buffer[..pos], hashOutput, out _); - Debug.Assert(hashSucceeded); - return Convert.ToBase64String(hashOutput); + if (cacheBoundary.VaryByHeader is not null) + { + AppendDelimitedValues(hash, "VaryByHeader", cacheBoundary.VaryByHeader, name => (string?)request.Headers[name]); } - finally + + if (cacheBoundary.VaryByCookie is not null) { - if (pool is not null) - { - ArrayPool.Shared.Return(pool, clearArray: true); - } + AppendDelimitedValues(hash, "VaryByCookie", cacheBoundary.VaryByCookie, name => request.Cookies[name]); } - } - private static void AppendUtf8(ref Span buffer, ref byte[]? pool, ref int pos, string? value) - { - if (string.IsNullOrEmpty(value)) + if (cacheBoundary.VaryByUser is true) { - return; + AppendString(hash, "||VaryByUser||"); + AppendString(hash, httpContext.User.Identity?.Name); } - int written; - while (!Encoding.UTF8.TryGetBytes(value, buffer[pos..], out written)) + if (cacheBoundary.VaryByCulture is true) { - GrowBuffer(ref pool, ref buffer, value.Length * 4 + pos); + AppendString(hash, "||VaryByCulture||"); + AppendString(hash, CultureInfo.CurrentCulture.Name); + AppendString(hash, "||"); + AppendString(hash, CultureInfo.CurrentUICulture.Name); } - pos += written; + Span hashOutput = stackalloc byte[SHA256.HashSizeInBytes]; + var hashSucceeded = hash.TryGetHashAndReset(hashOutput, out _); + Debug.Assert(hashSucceeded); + return Convert.ToBase64String(hashOutput); } private static void AppendDelimitedValues( - ref Span buffer, ref byte[]? pool, ref int pos, - string collectionName, string commaSeparated, Func valueAccessor) + IncrementalHash hash, + string collectionName, string separatedValues, Func valueAccessor) { - var names = commaSeparated.Split(_separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var names = separatedValues.Split(_separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (names.Length == 0) { return; } - AppendUtf8(ref buffer, ref pool, ref pos, "||"); - AppendUtf8(ref buffer, ref pool, ref pos, collectionName); - AppendUtf8(ref buffer, ref pool, ref pos, "("); + AppendString(hash, "||"); + AppendString(hash, collectionName); + AppendString(hash, "("); for (var i = 0; i < names.Length; i++) { - if (i > 0) + if (string.IsNullOrEmpty(valueAccessor(names[i]))) { - AppendUtf8(ref buffer, ref pool, ref pos, "||"); + continue; } - - AppendUtf8(ref buffer, ref pool, ref pos, names[i]); - AppendUtf8(ref buffer, ref pool, ref pos, "||"); - AppendUtf8(ref buffer, ref pool, ref pos, valueAccessor(names[i])); + AppendString(hash, "||"); + AppendString(hash, names[i]); + AppendString(hash, "||"); + AppendString(hash, valueAccessor(names[i])); } - AppendUtf8(ref buffer, ref pool, ref pos, ")"); + AppendString(hash, ")"); } - private static void GrowBuffer(ref byte[]? pool, ref Span buffer, int? size = null) + private static void AppendString(IncrementalHash hash, string? value) { - var newPool = pool is null - ? ArrayPool.Shared.Rent(size ?? 2048) - : ArrayPool.Shared.Rent(Math.Max(size ?? pool.Length * 2, pool.Length * 2)); - buffer.CopyTo(newPool); - if (pool is not null) + if (!string.IsNullOrEmpty(value)) { - ArrayPool.Shared.Return(pool, clearArray: true); + hash.AppendData(Encoding.UTF8.GetBytes(value)); } - - pool = newPool; - buffer = newPool; } } diff --git a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs index 686b8e6d6870..74b6226c679d 100644 --- a/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs +++ b/src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs @@ -6,29 +6,20 @@ namespace Microsoft.AspNetCore.Components.Endpoints; /// /// Provides a store for caching rendered component output as a JSON template-with-holes representation. /// -internal abstract class CacheBoundaryStore : IDisposable +internal interface ICacheBoundaryStore : IDisposable { - protected static readonly TimeSpan DefaultExpiration = TimeSpan.FromSeconds(30); - /// /// Gets a cached JSON template for the specified key, or null on cache miss. /// - public abstract string? Get(string key); + string? Get(string key); /// /// Stores a JSON template for the specified key. /// - public abstract void Set(string key, string json, CacheStoreOptions options = default); + void Set(string key, string json, CacheStoreOptions options = default); /// /// Removes all cached entries. Used primarily for testing scenarios. /// - public virtual void Clear() - { - } - - /// - public virtual void Dispose() - { - } + void Clear() { } } diff --git a/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs index 6e4d36a42422..0eb476169554 100644 --- a/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs +++ b/src/Components/Endpoints/src/CacheBoundary/MemoryCacheBoundaryStore.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -internal sealed class MemoryCacheBoundaryStore : CacheBoundaryStore +internal sealed class MemoryCacheBoundaryStore : ICacheBoundaryStore { private readonly MemoryCache _cache; @@ -18,13 +18,13 @@ public MemoryCacheBoundaryStore(IOptions options) }); } - public override string? Get(string key) + public string? Get(string key) { _cache.TryGetValue(key, out string? cached); return cached; } - public override void Set(string key, string json, CacheStoreOptions options = default) + public void Set(string key, string json, CacheStoreOptions options = default) { var entryOptions = new MemoryCacheEntryOptions { @@ -42,7 +42,7 @@ public override void Set(string key, string json, CacheStoreOptions options = de } else { - entryOptions.AbsoluteExpirationRelativeToNow = options.ExpiresAfter ?? DefaultExpiration; + entryOptions.AbsoluteExpirationRelativeToNow = options.ExpiresAfter ?? RazorComponentsServiceOptions.DefaultCacheBoundaryExpiration; } if (options.Priority.HasValue) @@ -53,12 +53,12 @@ public override void Set(string key, string json, CacheStoreOptions options = de _cache.Set(key, json, entryOptions); } - public override void Clear() + public void Clear() { _cache.Clear(); } - public override void Dispose() + public void Dispose() { _cache.Dispose(); } diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 036253d08dd7..68a0d567c794 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -74,7 +74,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); services.AddTempData(); - services.TryAddSingleton(); + 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 cb3b22d48bdc..2f2cff13eecc 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs @@ -121,4 +121,5 @@ public long CacheBoundarySizeLimit } 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/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index 60b5c3da9512..6d7286ecfc8f 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -5,8 +5,6 @@ using System.Globalization; using System.Reflection; using System.Reflection.Metadata; -using System.Security.Cryptography; -using System.Text; using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; @@ -19,7 +17,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class EndpointComponentState : ComponentState { private static readonly ConcurrentDictionary _streamRenderingAttributeByComponentType = new(); - private static readonly ConcurrentDictionary<(string, string?), string> _treePositionKeyCache = new(); + private static readonly string _cacheBoundaryTypeName = typeof(CacheBoundary).FullName!; static EndpointComponentState() @@ -27,11 +25,11 @@ static EndpointComponentState() if (HotReloadManager.IsSupported) { HotReloadManager.Default.OnDeltaApplied += _streamRenderingAttributeByComponentType.Clear; - HotReloadManager.Default.OnDeltaApplied += _treePositionKeyCache.Clear; } } private readonly EndpointHtmlRenderer _renderer; + public EndpointComponentState(Renderer renderer, int componentId, IComponent component, ComponentState? parentComponentState) : base(renderer, componentId, component, parentComponentState) { @@ -50,13 +48,15 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com StreamRendering = parentEndpointComponentState?.StreamRendering ?? false; } - if (component is CacheBoundary cacheBoundary) + if (component is CacheBoundary cacheBoundary && parentComponentState is not null) { - var ancestorTypeName = parentComponentState?.Component?.GetType().FullName ?? ""; + var ancestorTypeName = parentComponentState.Component?.GetType().FullName ?? ""; cacheBoundary.TreePositionKeyFactory = () => { - var (componentKey, sequence) = GetComponentKeyAndSequence(); - return ComputeTreePositionKey(ancestorTypeName, componentKey, sequence); + var sequence = FindSequenceInParent(parentComponentState, cacheBoundary); + var componentKey = GetComponentKey(); + var keyString = ComponentKeyHelper.FormatSerializableKey(componentKey); + return ComputeTreePositionKey(ancestorTypeName, sequence, keyString); }; } } @@ -79,77 +79,37 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com return base.GetComponentKey(); } - private (object? Key, int Sequence) GetComponentKeyAndSequence() - { - if (ParentComponentState is not { } parentState) - { - return (null, 0); - } - - var frames = _renderer.GetRenderTreeFrames(parentState.ComponentId); - for (var i = 0; i < frames.Count; i++) - { - ref var currentFrame = ref frames.Array[i]; - if (currentFrame.FrameType == RenderTreeFrameType.Component && - ReferenceEquals(Component, currentFrame.Component)) - { - return (currentFrame.ComponentKey, currentFrame.Sequence); - } - } - - return (null, 0); - } - /// /// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection. /// public static void UpdateApplication(Type[]? _) { _streamRenderingAttributeByComponentType.Clear(); - _treePositionKeyCache.Clear(); } - private static string ComputeTreePositionKey(string ancestorTypeName, object? componentKey, int sequence) + private static string ComputeTreePositionKey(string ancestorTypeName, int sequence, string? keyString) { - var keyString = FormatSerializableKey(componentKey); - var seqString = sequence.ToString(CultureInfo.InvariantCulture); - - if (keyString is null) - { - return _treePositionKeyCache.GetOrAdd((ancestorTypeName, seqString), static parts => - Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes( - string.Concat(parts.Item1, ".", _cacheBoundaryTypeName, ".", parts.Item2))))); - } - - return _treePositionKeyCache.GetOrAdd((ancestorTypeName, string.Concat(seqString, ".", keyString)), static parts => - Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes( - string.Concat(parts.Item1, ".", _cacheBoundaryTypeName, ".", parts.Item2))))); + return string.Concat( + ancestorTypeName, ".", + _cacheBoundaryTypeName, "#", + sequence.ToString(CultureInfo.InvariantCulture), + keyString is not null ? "." : "", + keyString); } - private static string? FormatSerializableKey(object? key) + // 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) { - if (key is null) - { - return null; - } - - var keyType = key.GetType(); - var isSerializable = Type.GetTypeCode(keyType) != TypeCode.Object - || keyType == typeof(Guid) - || keyType == typeof(DateTimeOffset) - || keyType == typeof(DateOnly) - || keyType == typeof(TimeOnly); - - if (!isSerializable) + var frames = _renderer.GetRenderTreeFrames(parentState.ComponentId); + for (var i = 0; i < frames.Count; i++) { - return null; + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component && ReferenceEquals(frame.Component, target)) + { + return frame.Sequence; + } } - - return key switch - { - IFormattable formattable => formattable.ToString("", CultureInfo.InvariantCulture), - IConvertible convertible => convertible.ToString(CultureInfo.InvariantCulture), - _ => key.ToString(), - }; + return 0; } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index ccf4b616a3c6..0276cdfd1515 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -283,9 +283,6 @@ internal static ServerComponentInvocationSequence GetOrCreateInvocationId(HttpCo return (ServerComponentInvocationSequence)result!; } - internal ArrayRange GetRenderTreeFrames(int componentId) - => GetCurrentRenderTreeFrames(componentId); - internal (int sequence, object? key) GetSequenceAndKey(ComponentState boundaryComponentState) { if (boundaryComponentState is null || boundaryComponentState.Component is not SSRRenderModeBoundary boundary) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 7aff9691da9c..5c8fbd575bcb 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -40,7 +40,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer { private readonly IServiceProvider _services; private readonly RazorComponentsServiceOptions _options; - private readonly CacheBoundaryStore? _cacheStore; + private readonly ICacheBoundaryStore? _cacheStore; private Task? _servicesInitializedTask; private HttpContext _httpContext = default!; // Always set at the start of an inbound call private ResourceAssetCollection? _resourceCollection; @@ -61,12 +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(); + _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/test/CacheBoundaryRenderTest.cs b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs index 7dad916547cd..23847e84d774 100644 --- a/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs +++ b/src/Components/Endpoints/test/CacheBoundaryRenderTest.cs @@ -84,16 +84,18 @@ private static void AssertContainsText(ArrayRange frames, strin Assert.Fail($"Expected to find text frame '{expectedText}' but it was not present."); } - private sealed class TestCacheStore : CacheBoundaryStore + private sealed class TestCacheStore : ICacheBoundaryStore { public Dictionary Data { get; } = new(); public string? ReturnForAnyKey { get; set; } - public override string? Get(string key) + public string? Get(string key) => ReturnForAnyKey ?? (Data.TryGetValue(key, out var value) ? value : null); - public override void Set(string key, string json, CacheStoreOptions options = default) + public void Set(string key, string json, CacheStoreOptions options = default) => Data[key] = json; + + public void Dispose() { } } private sealed class TestLogger : ILogger 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/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index b6837827efe8..240cdf6f7d38 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -144,12 +144,12 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen endpoints.MapGet("cache-component/clear", (HttpContext context) => { var storeType = typeof(RazorComponentsServiceOptions).Assembly - .GetType("Microsoft.AspNetCore.Components.Endpoints.CacheBoundaryStore") - ?? throw new InvalidOperationException("CacheBoundaryStore type not found. The internal type may have been renamed or moved."); + .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("CacheBoundaryStore is not registered in DI."); + ?? throw new InvalidOperationException("ICacheBoundaryStore is not registered in DI."); var clearMethod = storeType.GetMethod("Clear") - ?? throw new InvalidOperationException("CacheBoundaryStore.Clear() method not found."); + ?? throw new InvalidOperationException("ICacheBoundaryStore.Clear() method not found."); clearMethod.Invoke(store, null); InnerCachedComponent.ResetRenderCount(); }); From 45caec5555304491f064f9c4d7f00a78a9eecc57 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Fri, 24 Apr 2026 10:57:48 +0200 Subject: [PATCH 6/6] Clean-up Co-authored-by: Copilot --- .../Endpoints/src/Rendering/EndpointComponentState.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index 6d7286ecfc8f..cab093814922 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -82,10 +82,7 @@ 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(); - } + public static void UpdateApplication(Type[]? _) => _streamRenderingAttributeByComponentType.Clear(); private static string ComputeTreePositionKey(string ancestorTypeName, int sequence, string? keyString) {