Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ace51a8
Implement CacheComponent for Blazor SSR output caching
dariatiurina Apr 13, 2026
476c95b
Fixes
dariatiurina Apr 14, 2026
bde38ec
Improvements
dariatiurina Apr 16, 2026
741aec2
Feedback
dariatiurina Apr 22, 2026
130c214
Merge branch 'main' into 55520-cache
dariatiurina Apr 23, 2026
b55dcb2
Feedback fix
dariatiurina Apr 24, 2026
45caec5
Clean-up
dariatiurina Apr 24, 2026
714f48c
Fix bug with hole replay
dariatiurina May 5, 2026
8a406a8
Fix rendermode bug
dariatiurina May 5, 2026
dfb4b28
Changed the way annotation works
dariatiurina May 13, 2026
1f960b7
Merge branch 'main' into 55520-cache
dariatiurina May 13, 2026
5473fa3
Merge branch 'main' into 55520-cache
dariatiurina May 20, 2026
2f26484
Changed CacheBoundary to use RenderFragment serialization
dariatiurina May 20, 2026
1ee7752
Fixes
dariatiurina May 20, 2026
c9381e9
Fix for QuickGrid
dariatiurina May 20, 2026
b3ff152
Clean-up
dariatiurina May 22, 2026
d66bb6c
Clean-up
dariatiurina May 22, 2026
eec2880
Fix hot reload
dariatiurina May 25, 2026
cdc47e9
Fix
dariatiurina May 25, 2026
615059e
Fix to async lifecycle and add support for HybridCache
dariatiurina Jun 1, 2026
d51ff7f
Fixes
dariatiurina Jun 1, 2026
ffa790e
Clean-up
dariatiurina Jun 4, 2026
47302c0
Clean-up
dariatiurina Jun 4, 2026
fb295d1
Added wildcard to VaryByQuery and sorting
dariatiurina Jun 10, 2026
41b9d7b
Fix bug with cancellation
dariatiurina Jun 10, 2026
b4781b8
Throwing when not enabled
dariatiurina Jun 10, 2026
bde200c
Merge branch 'main' into 55520-cache
dariatiurina Jun 11, 2026
ac89d80
Fix deadlock
dariatiurina Jun 11, 2026
caf1bd7
E2E tests additions
dariatiurina Jun 12, 2026
e24ed6c
Revert tests
dariatiurina Jun 15, 2026
b35d087
Feedback
dariatiurina Jun 15, 2026
6c8bc98
Added consistency with PersistentComponentState
dariatiurina Jun 15, 2026
358808b
Fixed forms for cache
dariatiurina Jun 16, 2026
295b64f
Throw when we have RF as parameter.
dariatiurina Jun 17, 2026
65155eb
Refactoring
dariatiurina Jun 17, 2026
e2440d7
Service creation
dariatiurina Jun 17, 2026
b6eb166
Refactoring
dariatiurina Jun 17, 2026
ceb180b
Refactoring and clean-up
dariatiurina Jun 18, 2026
e422b9c
Clean-up
dariatiurina Jun 18, 2026
223fc8f
Apply suggestions from code review
dariatiurina Jun 18, 2026
0718353
Changed storage choice strategy
dariatiurina Jun 18, 2026
2179b36
Fixed xml
dariatiurina Jun 18, 2026
9fde434
Fix
dariatiurina Jun 18, 2026
5ad0069
Fix HybridCache
dariatiurina Jun 19, 2026
77f612d
Feedback
dariatiurina Jun 19, 2026
39a3414
Fix flakyness
dariatiurina Jun 22, 2026
d9e6b95
Clean-up
dariatiurina Jun 22, 2026
e8b7a3b
Merge branch 'main' into 55520-cache
dariatiurina Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Components/Authorization/src/AuthorizeViewCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Components.Authorization;
/// <summary>
/// A base class for components that display differing content depending on the user's authorization status.
/// </summary>
[CacheBoundaryPolicy(Disallow = true, VaryBy = CacheBoundaryVaryBy.User)]
public abstract class AuthorizeViewCore : ComponentBase
{
private AuthenticationState? currentAuthenticationState;
Expand Down
52 changes: 52 additions & 0 deletions src/Components/Components/src/CacheBoundaryPolicyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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;

/// <summary>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// Specifies how a component interacts with an enclosing CacheBoundary.
/// When present, the component is treated as a "hole" in the cached output: the
/// component is instantiated and executes its own lifecycle on every request rather
/// than being served from the cached HTML. Its parameters are captured at the time the
/// cache entry is produced and are replayed unchanged on subsequent requests, so values
/// closed over by the surrounding render remain those of the original render.
/// Optionally, set <see cref="VaryBy"/> to lift the exclusion when the cache boundary
/// varies by the specified dimensions, in which case the component is included in
/// the cached output like any other.
/// <para>
/// <see cref="RenderFragment"/> parameters are not supported on hole components, because the
/// hole re-renders on every request while its parameters are captured once and replayed; a
/// captured <see cref="RenderFragment"/> would be frozen to the content of the first render.
/// Encountering a hole with a <see cref="RenderFragment"/> parameter throws an
/// <see cref="InvalidOperationException"/>. To fix this, remove the <see cref="RenderFragment"/>
/// parameter or move the component outside the CacheBoundary.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class CacheBoundaryPolicyAttribute : Attribute
{
/// <summary>
/// Gets or sets a value indicating whether encountering this component inside
/// a cache boundary is disallowed and should throw an <see cref="InvalidOperationException"/>.
/// Use this for components whose parameters (delegates, expressions, or complex
/// objects) would not behave correctly if captured once and replayed on later
/// requests; for example because they close over per-request state or
/// because the rendered output depends on values that change between requests.
/// The exception is suppressed when the enclosing CacheBoundary varies by all
/// of the dimensions specified by <see cref="VaryBy"/>.
/// Defaults to <see langword="false"/>.
/// </summary>
public bool Disallow { get; set; }

/// <summary>
/// Gets or sets the vary-by dimensions that, when all active on the enclosing
/// CacheBoundary, lift the exclusion. When lifted, the component participates
/// in the cached output like any other component (its captured parameters and
/// child content are stored in the cache entry and replayed on cache hits),
/// and the cache key already distinguishes the values of those dimensions so
/// the replay is correct. When not lifted, the component is treated as a hole
/// (or throws, when <see cref="Disallow"/> is <see langword="true"/>).
/// Defaults to <see cref="CacheBoundaryVaryBy.None"/>.
/// </summary>
public CacheBoundaryVaryBy VaryBy { get; set; }
}
48 changes: 48 additions & 0 deletions src/Components/Components/src/CacheBoundaryVaryBy.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Describes which vary-by dimensions are active on an enclosing CacheBoundary.
/// Used as flags in <see cref="CacheBoundaryPolicyAttribute"/> to express conditional
/// cache exclusion, and internally to communicate the active dimensions to the renderer.
/// </summary>
[Flags]
public enum CacheBoundaryVaryBy
{
/// <summary>
/// No vary-by dimensions.
/// </summary>
None = 0,

/// <summary>
/// Vary by query string parameters.
/// </summary>
Query = 1 << 0,

/// <summary>
/// Vary by route parameters.
/// </summary>
Route = 1 << 1,

/// <summary>
/// Vary by HTTP header values.
/// </summary>
Header = 1 << 2,

/// <summary>
/// Vary by cookie values.
/// </summary>
Cookie = 1 << 3,

/// <summary>
/// Vary by the authenticated user.
/// </summary>
User = 1 << 4,
Comment thread
dariatiurina marked this conversation as resolved.

/// <summary>
/// Vary by culture.
/// </summary>
Culture = 1 << 5,
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

<ItemGroup>
<Compile Include="$(ComponentsSharedSourceRoot)src\ArrayBuilder.cs" LinkBase="RenderTree" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ComponentKeyHelper.cs" LinkBase="Shared" />
<Compile Include="$(ComponentsSharedSourceRoot)src\JsonSerializerOptionsProvider.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\HotReloadManager.cs" LinkBase="HotReload" />
<Compile Include="$(ComponentsSharedSourceRoot)src\RootTypeCache.cs" LinkBase="Shared" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,8 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta

private static ReadOnlySpan<char> 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<byte> keyBuffer, int? size = null)
Expand All @@ -154,7 +145,7 @@ private static void GrowBuffer(ref byte[]? pool, ref Span<byte> 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;
}
Expand Down Expand Up @@ -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;
}
}
14 changes: 14 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
#nullable enable
Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute
Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.CacheBoundaryPolicyAttribute() -> void
Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.Disallow.get -> bool
Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.Disallow.set -> void
Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.VaryBy.get -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute.VaryBy.set -> void
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Cookie = 8 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Culture = 32 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Header = 4 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.None = 0 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Query = 1 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.Route = 2 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
Microsoft.AspNetCore.Components.CacheBoundaryVaryBy.User = 16 -> Microsoft.AspNetCore.Components.CacheBoundaryVaryBy
abstract Microsoft.AspNetCore.Components.CascadingParameterSubscription.Dispose() -> void
abstract Microsoft.AspNetCore.Components.CascadingParameterSubscription.GetCurrentValue() -> object?
Microsoft.AspNetCore.Components.CascadingParameterSubscription
Expand Down
151 changes: 151 additions & 0 deletions src/Components/Endpoints/src/CacheBoundary/CacheBoundary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// 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.
/// </summary>
public sealed class CacheBoundary : IComponent, IDisposable
{
private RenderHandle _renderHandle;

/// <summary>
/// Gets or sets the content to be cached.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }

/// <summary>
/// Gets or sets an explicit cache key for disambiguation when multiple
/// <see cref="CacheBoundary"/> instances share the same component ancestor.
/// </summary>
[Parameter]
public string? CacheKey { get; set; }

/// <summary>
/// Gets or sets whether caching is enabled. Defaults to <c>true</c>.
/// </summary>
[Parameter]
public bool Enabled { get; set; } = true;

/// <summary>
/// Gets or sets how long after creation the cache entry should be evicted.
/// </summary>
[Parameter]
public TimeSpan? ExpiresAfter { get; set; }

/// <summary>
/// Gets or sets the absolute <see cref="DateTimeOffset"/> when the cache entry should be evicted.
/// </summary>
[Parameter]
public DateTimeOffset? ExpiresOn { get; set; }

/// <summary>
/// Gets or sets how long after last access the cache entry should be evicted.
/// Not supported when the cache boundary store uses <c>HybridCache</c>.
/// </summary>
[Parameter]
public TimeSpan? ExpiresSliding { get; set; }

/// <summary>
/// Gets or sets the <see cref="CacheItemPriority"/> policy for the cache entry.
/// Not supported when the cache boundary store uses <c>HybridCache</c>.
/// </summary>
[Parameter]
public CacheItemPriority? Priority { get; set; }

/// <summary>
/// Gets or sets a comma-separated list of query string parameter names to vary the cache by.
/// Use <c>"*"</c> to vary by all query string parameters.
/// </summary>
[Parameter]
public string? VaryByQuery { get; set; }

/// <summary>
/// Gets or sets a comma-separated list of route parameter names to vary the cache by.
/// </summary>
[Parameter]
public string? VaryByRoute { get; set; }

/// <summary>
/// Gets or sets a comma-separated list of HTTP header names to vary the cache by.
/// </summary>
[Parameter]
public string? VaryByHeader { get; set; }

/// <summary>
/// Gets or sets a comma-separated list of cookie names to vary the cache by.
/// </summary>
[Parameter]
public string? VaryByCookie { get; set; }

/// <summary>
/// Gets or sets whether to vary the cache by the authenticated user identity.
/// </summary>
[Parameter]
public bool? VaryByUser { get; set; }

/// <summary>
/// Gets or sets whether to vary the cache by the current culture.
/// </summary>
[Parameter]
public bool? VaryByCulture { get; set; }

/// <summary>
/// Gets or sets a custom string value to vary the cache by.
/// </summary>
[Parameter]
public string? VaryBy { get; set; }

[Inject] internal CacheBoundaryService? CacheService { get; set; }
[CascadingParameter] internal HttpContext? HttpContext { get; set; }
internal Func<string>? TreePositionKeyFactory { get; set; }
internal string? TreePositionKey => TreePositionKeyFactory?.Invoke();

internal bool IsInStreamingContext { get; set; }

// The per-render coordination state produced by CacheBoundaryService. Null when caching is inactive
// for this render; the renderer reads it to drive capture.
internal CacheBoundaryRenderState? RenderState { get; private set; }

/// <inheritdoc/>
void IComponent.Attach(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}

/// <inheritdoc/>
async Task IComponent.SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);

RenderState = HttpContext is { } httpContext && CacheService is { } cacheService
? await cacheService.PrepareAsync(this, httpContext)
: null;

_renderHandle.Render(BuildRenderTree);
}

private void BuildRenderTree(RenderTreeBuilder builder)
{
var content = RenderState?.Content ?? ChildContent;
content?.Invoke(builder);
}

/// <inheritdoc/>
public void Dispose()
{
if (RenderState is { } state)
{
CacheBoundaryService.OnBoundaryDisposed(state);
}
}
}
Loading