Skip to content

CacheBoundary support for Blazor#65772

Open
dariatiurina wants to merge 19 commits into
dotnet:mainfrom
dariatiurina:55520-cache
Open

CacheBoundary support for Blazor#65772
dariatiurina wants to merge 19 commits into
dotnet:mainfrom
dariatiurina:55520-cache

Conversation

@dariatiurina
Copy link
Copy Markdown
Contributor

@dariatiurina dariatiurina commented Mar 13, 2026

CacheBoundary support for Blazor

Description

Introduces CacheBoundary — a new Blazor component that enables output caching of server-side rendered (SSR) component subtrees. On cache hit, child components inside a CacheBoundary are not instantiated or rendered; instead, the previously captured output is deserialized and replayed directly as render tree frames.

For more details see design document.

How It Works

Cache Miss Path

  1. CacheBoundary.BuildRenderTree computes a cache key via CacheBoundaryKeyResolver using tree position, CacheKey, and VaryBy* dimension values from the current HttpContext.
  2. On cache miss, EndpointHtmlRenderer.WriteComponentHtml wraps the output TextWriter in a CacheBoundaryTextWriter that tee-writes to both the response and an internal segment buffer.
  3. When hole components are encountered during capture, the writer pauses capture, records a hole entry (component type, sequence, key, render mode), and resumes.
  4. After rendering completes, CacheBoundaryTextWriter.GetJson() serializes the captured entries — matching hole entries against the serialized ChildContent render tree (via RenderFragmentSerializer.SerializeFrames) — and stores the result in the cache.

Cache Hit Path

  1. CacheBoundary.BuildRenderTree retrieves the cached JSON from the store.
  2. The JSON is deserialized into a list of RenderTreeNode objects (a component tree representation with markup nodes, component nodes, attributes, and nested RenderFragment parameters).
  3. RenderFragmentSerializer.Deserialize converts the nodes back into a RenderFragment that emits render tree frames directly into the builder — no child component instances are created and no lifecycle methods fire.

Hole Detection

A "hole" is a component whose subtree must be re-rendered on every request because its content depends on per-request state (auth identity, form bindings, interactive render mode boundaries, etc.).

Detection is attribute-based via [CacheBoundaryPolicy] and lives in EndpointHtmlRenderer.IsHoleComponent(Type, CacheBoundaryVaryBy):

  1. The component type is checked for [CacheBoundaryPolicy] (lookup is cached per-type in a ConcurrentDictionary).
  2. If the attribute is absent → not a hole; the component is baked into cached HTML.
  3. If the attribute is present with VaryBy = None → unconditional hole (e.g., AntiforgeryToken, HeadOutlet, SSRRenderModeBoundary).
  4. If the attribute has VaryBy flags set → conditional hole. The component is a hole only when the enclosing CacheBoundary's active VaryBy dimensions do not cover all of the attribute's required dimensions.
    • Example: AuthorizeViewCore is [CacheBoundaryPolicy(Throw = true, VaryBy = CacheBoundaryVaryBy.User)]. If the boundary sets VaryByUser="true", the per-user dimension is in the cache key, so AuthorizeView is safe to cache and is not a hole. Otherwise it throws.
  5. If Throw = true and the component would be a hole → an InvalidOperationException is thrown instead, telling the developer to move the component outside the cache boundary. This is used for components whose parameters (delegates, expressions, complex objects) cannot be serialized.
  6. Components whose EndpointComponentState.StreamRendering is true also force a hole (streaming children can't update past a cached parent).

Built-in components marked with [CacheBoundaryPolicy]

Component Attribute Behavior
AuthorizeViewCore [CacheBoundaryPolicy(Throw = true, VaryBy = User)] Throws unless VaryByUser is active on the boundary
EditForm [CacheBoundaryPolicy(Throw = true)] Always throws — non-serializable parameters
InputBase<T> [CacheBoundaryPolicy(Throw = true)] Always throws
ValidationMessage<T> [CacheBoundaryPolicy(Throw = true)] Always throws
ValidationSummary [CacheBoundaryPolicy(Throw = true)] Always throws
DisplayName<T> [CacheBoundaryPolicy(Throw = true)] Always throws
Label<T> [CacheBoundaryPolicy(Throw = true)] Always throws
Virtualize<T> [CacheBoundaryPolicy(Throw = true)] Always throws
QuickGrid<TGridItem> [CacheBoundaryPolicy(Throw = true, VaryBy = Query)] Throws unless VaryByQuery is active on the boundary
AntiforgeryToken [CacheBoundaryPolicy] Unconditional hole
HeadOutlet [CacheBoundaryPolicy] Unconditional hole
SSRRenderModeBoundary [CacheBoundaryPolicy] Unconditional hole

New Public API

  • CacheBoundary (Microsoft.AspNetCore.Components.CacheBoundary) — wraps child content and caches its rendered HTML during SSR. Parameters:

    • ChildContent — the content to cache
    • CacheKey — explicit key for disambiguation when multiple CacheBoundary instances share the same parent
    • Enabled — toggle caching on/off (default true)
    • ExpiresAfter / ExpiresOn / ExpiresSliding — expiration policies
    • PriorityCacheItemPriority for the cache entry
    • VaryByQuery, VaryByRoute, VaryByHeader, VaryByCookie — vary cache by comma-separated request dimension names
    • VaryByUser — vary by authenticated identity
    • VaryByCulture — vary by current culture/UI culture
    • VaryBy — arbitrary custom string to vary by
  • CacheBoundaryPolicyAttribute (Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute) — attribute controlling how a component interacts with an enclosing CacheBoundary. When present, the component becomes a "hole" in the cached output. Properties:

    • Throw — when true, throws InvalidOperationException instead of creating a hole (for components with non-serializable parameters)
    • VaryByCacheBoundaryVaryBy flags that lift the exclusion when the boundary varies by those dimensions
  • CacheBoundaryVaryBy (Microsoft.AspNetCore.Components.CacheBoundaryVaryBy) — [Flags] enum: None, Query, Route, Header, Cookie, User, Culture

  • RazorComponentsServiceOptions.CacheBoundarySizeLimit — configurable maximum size (bytes) for the in-memory cache (default 100 MB)

Internal Infrastructure

  • CacheBoundaryKeyResolver — computes a deterministic SHA-256 cache key from the component's tree position, CacheKey, and all VaryBy* dimension values from the current HttpContext.
  • ICacheBoundaryStore / MemoryCacheBoundaryStore — pluggable cache backend; default uses MemoryCache with a configurable size limit. Registered as a singleton in AddRazorComponents.
  • CacheStoreOptions — passes expiration and priority settings from the component to the store.
  • CacheBoundaryTextWriter — a TextWriter decorator that tee-writes to both the response and an internal segment buffer, with pause/resume support for hole creation. On completion, GetJson() serializes the captured entries by matching hole entries against the component tree produced by RenderFragmentSerializer.SerializeFrames.
  • EndpointComponentState — assigns a TreePositionKeyFactory to each CacheBoundary instance, producing a key from the parent component type, CacheBoundary type name, the sequence number within the parent's render tree, and any @key directive.
  • RenderFragmentSerializer — shared serializer/deserializer for render tree frames → RenderTreeNode JSON representation. Updated to capture Sequence and RenderModeName on component nodes (previously ComponentRenderMode frames were skipped during serialization). The deserializer now restores render mode via AddComponentRenderMode when replaying cached component nodes.
  • ComponentKeyHelper — shared utility (new file in src/Components/Shared/src/) for serializing @key values (supports primitive types, Guid, DateTimeOffset, DateOnly, TimeOnly). Extracted from PersistentStateValueProviderKeyResolver so the logic is reused by both persistent state and cache boundary key computation.

Testing

  • Unit tests (3 test files in src/Components/Endpoints/test/):

    • CacheBoundaryKeyResolverTest — deterministic key generation, VaryBy* dimension isolation, collision resistance between different dimensions, delimiter injection resistance
    • CacheBoundaryRenderTest — fallback to fresh render on deserialization failure with warning log, cache hit skipping ChildContent invocation
    • IsHoleComponentTest — hole classification for components with/without the attribute, conditional exclusion with VaryBy flags, Throw behavior, inherited attribute detection
  • E2E tests (CacheBoundaryTest in src/Components/test/E2ETest/Tests/):

    • Verifies cached content persists across navigations while non-cached content changes
    • Verifies Enabled=false disables caching
    • Verifies holes (forms, [CacheBoundaryPolicy]-marked components) remain functional across cache hits
    • Verifies nested CacheBoundary does not re-execute inner component on outer cache hit
    • Verifies CacheKey enables distinct cache entries in loops
    • Verifies multiple holes of the same component type within a single CacheBoundary preserve correct order across cache hits

Fixes #55520
Fixes #65756

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dariatiurina dariatiurina marked this pull request as ready for review April 15, 2026 18:02
@dariatiurina dariatiurina requested a review from a team as a code owner April 15, 2026 18:02
Copilot AI review requested due to automatic review settings April 15, 2026 18:02
@dariatiurina dariatiurina self-assigned this Apr 15, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds <CacheComponent> support for Blazor SSR by capturing rendered HTML into a cache (with “hole” components that always re-render) and restoring cached output on subsequent requests.

Changes:

  • Introduces CacheComponent / NotCacheComponent plus key computation, JSON segment (“template + holes”) format, and an IMemoryCache-backed store.
  • Hooks SSR rendering to capture cacheable output and record hole segments during EndpointHtmlRenderer.WriteComponentHtml.
  • Adds unit tests (JSON, key resolver, hole classification, writer behavior) and E2E coverage via a new test page and endpoints for clearing/observing cache behavior.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Components/Endpoints/src/CacheComponent/CacheComponent.cs Public SSR cache component that restores cached HTML and reconstructs holes.
src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs Public opt-out marker that forces fresh rendering inside cached trees.
src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs Builds deterministic cache keys with optional vary-by dimensions.
src/Components/Endpoints/src/CacheComponent/CacheComponentJson.cs JSON serialization for cached HTML + hole segments.
src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs Store abstraction for cached JSON entries.
src/Components/Endpoints/src/CacheComponent/MemoryCacheComponentStore.cs MemoryCache-backed store honoring expiration/priority and size limit.
src/Components/Endpoints/src/CacheComponent/CacheStoreOptions.cs Internal options passed to the store when setting entries.
src/Components/Endpoints/src/CacheComponent/CacheComponentVaryBy.cs Internal flags used by hole classification logic.
src/Components/Endpoints/src/Rendering/CacheComponentTextWriter.cs TextWriter wrapper that captures HTML segments and injects hole segments.
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs Resolves the cache store from DI for use during SSR rendering.
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs Captures CacheComponent output on miss; pauses capture for hole components/boundaries.
src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs Adds CacheComponentSizeLimit option for MemoryCache sizing.
src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs Registers the default singleton cache store.
src/Components/Endpoints/src/PublicAPI.Unshipped.txt Declares new public API surface for shipping.
src/Components/Endpoints/test/CacheComponentJsonTest.cs Unit tests for segment serialization/deserialization.
src/Components/Endpoints/test/CacheComponentKeyResolverTest.cs Unit tests for key stability and vary-by dimensions.
src/Components/Endpoints/test/CacheComponentRenderTest.cs Unit tests for restore fallback/logging behavior.
src/Components/Endpoints/test/CacheComponentTextWriterTest.cs Unit tests for capture/pause/hole segment behavior.
src/Components/Endpoints/test/IsHoleComponentTest.cs Verifies which components are treated as holes.
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/CacheComponentTest.razor SSR test page exercising caching, holes, nesting, and looped keys.
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheComponentTest/InnerCachedComponent.razor Test component used to validate nested-cache behavior.
src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs Adds test-only endpoints to clear cache and read render-count.
src/Components/test/E2ETest/Tests/CacheComponentTest.cs E2E validations for caching, holes, nesting, and loop behavior.

Comment thread src/Components/Endpoints/src/CacheBoundary/CacheBoundaryJson.cs Outdated
Comment thread src/Components/test/E2ETest/Tests/CacheBoundaryTest.cs
Comment thread src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs Outdated
Comment thread src/Components/Endpoints/src/CacheComponent/CacheComponent.cs Outdated
Copy link
Copy Markdown
Member

@javiercn javiercn left a comment

Choose a reason for hiding this comment

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

Looks like a good starting point!

There are a few design issues that we need to solve, but the general direction looks good.

One super important aspect that we need to take into account in this feature is performance. We should minimize the number of allocations (especially intermediate allocations) and leverage pooling as much as possible to minimize the impact of caching on the throughput.

I have more feedback and I still have to look at the tests, but want to make the current set of points available.

Comment thread src/Components/Endpoints/src/CacheComponent/NotCacheComponent.cs Outdated

namespace Microsoft.AspNetCore.Components.Endpoints;

internal readonly struct CacheStoreOptions
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.

Are these options lifted from the MVC implementation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes

Comment thread src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs Outdated
Comment thread src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs Outdated
/// <summary>
/// Provides a store for caching rendered component output as a JSON template-with-holes representation.
/// </summary>
internal abstract class CacheComponentStore : IDisposable
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.

If there is no shared logic at all here, it might as well be an interface

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.

I see now there is only MemoryCacheComponentStore. I would have expected to see an implementation with HybridCache. See how PersistentComponentState sets up a similar set of options for the caching backend

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HybridCache was discussed in the design doc. The main problem is sync lifecycle of CacheBoundary.

Comment thread src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs Outdated
Comment thread src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs Outdated
Comment thread src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs Outdated
Comment thread src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs Outdated
@dariatiurina dariatiurina added this to the 11.0-preview5 milestone Apr 23, 2026
dariatiurina and others added 2 commits April 24, 2026 10:41
Co-authored-by: Copilot <copilot@github.com>
@dariatiurina dariatiurina changed the title Cache Component support for Blazor CacheBoundary support for Blazor Apr 24, 2026
Comment on lines +54 to +59
cacheBoundary.TreePositionKeyFactory = () =>
{
var sequence = FindSequenceInParent(parentComponentState, cacheBoundary);
var componentKey = GetComponentKey();
var keyString = ComponentKeyHelper.FormatSerializableKey(componentKey);
return ComputeTreePositionKey(ancestorTypeName, sequence, keyString);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do we want to have sequence as part of the key? We can drop it and tell people to use custom key on the CacheBoundary, but this will make them use it on even the simplest of examples:

<CacheBoundary> 
    <Header />
</CacheBoundary> 
<MainPage />
<CacheBoundary>
    <Footer /> 
</CacheBoundary> 

@dariatiurina
Copy link
Copy Markdown
Contributor Author

Question is still open with support for HybridCache and protection against multiple requests for cache at the same time for IMemoryCache. Problem lies in synchronous flow of cache and having retrieve from cache and saving to it be in two different places, when HybridCache expects it to be one (GetorCreate). Same problem prevents from implementing protection.

We can do manual layer that will adapt our system of cache with HybridCache, but then only advantage of HybridCache that will stay, will be support for distributed cache.

}

private long _cacheBoundarySizeLimit = 100_000_000;
internal static readonly TimeSpan DefaultCacheBoundaryExpiration = TimeSpan.FromSeconds(30);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DefaultCacheBoundaryExpiration is here, because of the possibility of future ICacheBoundaryStore implementations (if we will decide to move on with HybridCache). If not, we can move it to the MemoryCacheBoundaryStore.


if (componentState.Component is CacheBoundary cacheBoundary)
{
if (cacheBoundary.Enabled && cacheBoundary.ResolvedCacheKey is { } cacheKey)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Currently we support nested CacheBoundary components. This will make inner CacheBoundary cache being baked into the the outer one. This will work normally, if outer CacheBoundary has a timeout lower than inner one, because it will allow everything to render normally, outside inner cache, but in the opposite case, this will be useless. Question is whether we want to raise warning here, that we have recursive CacheBoundary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CacheBoundary Add a Blazor Cache component

3 participants