Skip to content

CacheBoundary support for Blazor#65772

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

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

Conversation

@dariatiurina

@dariatiurina dariatiurina commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

CacheBoundary support for Blazor

Description

Introduces CacheBoundary — a new Blazor component that enables output caching of server-side rendered (SSR) component subtrees. On a 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 the design document.

How It Works

All coordination behind a CacheBoundary (key resolution, single-flight stampede protection, store interaction, cached-content deserialization, capture lifecycle, and background persistence) lives in the internal CacheBoundaryService. The CacheBoundary component stays focused on rendering, and CacheBoundaryTextWriter on writing.

Cache miss path

  1. CacheBoundary.SetParametersAsync calls CacheBoundaryService.PrepareAsync, which computes a cache key via CacheBoundaryKeyResolver from the component's tree position, CacheKey, and the VaryBy* dimension values read from the current HttpContext, then calls ICacheBoundaryStore.GetOrCreateAsync. Single-flight coordination ensures concurrent requests for the same key share one factory invocation. Caching is skipped entirely for non-GET requests, when Enabled is false, and when the boundary is rendered inside a streaming render context.
  2. On a cache miss, EndpointHtmlRenderer.WriteComponentHtml wraps the output TextWriter in a CacheBoundaryTextWriter (via CacheBoundaryService.TryBeginWrite) that tee-writes to both the response and an internal segment buffer.
  3. When hole components are encountered during capture, the writer records a hole entry (the component node serialized from its parent's frames) between markup segments.
  4. After the subtree walk completes, CacheBoundaryService.EndCapture finalizes the capture and CacheBoundaryTextWriter.GetJson() serializes the captured segments — markup segments plus hole component nodes (serialized via RenderFragmentSerializer.SerializeFrames). The resulting JSON is handed back to the store, which persists it. Persistence is observed in the background; failures are logged and do not fail the render.

Cache hit path

  1. CacheBoundaryService.PrepareAsync returns the cached JSON, which CacheBoundary.BuildRenderTree renders.
  2. The JSON is deserialized into a list of RenderTreeNode objects (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.

Same-request key collisions

Multiple CacheBoundary instances in one request can resolve to the same key (e.g. a reusable component containing a CacheBoundary used more than once, or a loop without an explicit CacheKey). They share one cache entry. On a warm request both simply hit it. On a cold request the first becomes the single-flight creator; because its captured output is only produced during HTML emission (after the render pass reaches quiescence), a sibling that resolved to the same key renders fresh that one time instead of deadlocking, and shares the cached entry on subsequent requests. The set of in-flight creator keys is tracked per request in HttpContext.Items.

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 CacheBoundaryService.IsHoleComponent(Type, CacheBoundaryVaryBy) (called from EndpointHtmlRenderer during capture):

  1. The component type is checked for [CacheBoundaryPolicy] (lookup is cached per-type in a ConcurrentDictionary that is cleared on hot-reload).
  2. Attribute absent → not a hole; the component is baked into cached HTML.
  3. Attribute present with VaryBy = None → unconditional hole (e.g. AntiforgeryToken, HeadOutlet, SSRRenderModeBoundary).
  4. Attribute present with 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(Disallow = 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 Disallow = true and the component would be a hole → an InvalidOperationException is thrown instead, instructing the developer to move the component outside the boundary or configure the boundary to vary by the required dimensions. 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(Disallow = true, VaryBy = User)] Throws unless VaryByUser is active on the boundary
QuickGrid [CacheBoundaryPolicy(Disallow = true, VaryBy = Query)] Throws unless VaryByQuery is active on the boundary
Virtualize [CacheBoundaryPolicy(Disallow = true)] Always throws
AntiforgeryToken [CacheBoundaryPolicy] Unconditional hole
HeadOutlet [CacheBoundaryPolicy] Unconditional hole
SSRRenderModeBoundary [CacheBoundaryPolicy] Unconditional hole

Choosing the cache store

By default CacheBoundary uses an in-memory store (MemoryCacheBoundaryStore). Calling the new AddHybridCacheBoundaryStore() extension on IRazorComponentsBuilder switches it to a HybridCache-backed store (HybridCacheBoundaryStore); HybridCache must be registered separately. The selection is additive and independent of service registration order — it is recorded on the internal CacheBoundaryStoreOptions.UseHybridCache flag and the concrete ICacheBoundaryStore is resolved once in AddRazorComponents. With the HybridCache-backed store, ExpiresSliding and Priority are unsupported and throw NotSupportedException rather than being silently mapped to a different concept.

New public API

  • CacheBoundary (Microsoft.AspNetCore.Components) — 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 (ExpiresSliding is not supported by the HybridCache-backed store)
    • PriorityCacheItemPriority for the entry (not supported by the HybridCache-backed store)
    • VaryByQuery, VaryByRoute, VaryByHeader, VaryByCookie — vary by comma-separated request dimension names (VaryByQuery="*" varies by all query parameters)
    • VaryByUser — vary by authenticated identity
    • VaryByCulture — vary by current culture/UI culture
    • VaryBy — arbitrary custom string to vary by
  • CacheBoundaryPolicyAttribute (Microsoft.AspNetCore.Components) — controls how a component interacts with an enclosing CacheBoundary. When present, the component becomes a hole in the cached output. Properties:

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

  • RazorComponentsServiceOptions.CacheBoundarySizeLimit — configurable maximum size (bytes) for the in-memory cache. Default 100 MB; 0 configures a zero-byte limit so entries are not cached. Negative values throw ArgumentOutOfRangeException.

  • HybridCacheBoundaryStoreServiceCollectionExtensions.AddHybridCacheBoundaryStore(this IRazorComponentsBuilder) (Microsoft.Extensions.DependencyInjection) — opt in to a HybridCache-backed CacheBoundary store instead of the default in-memory store. HybridCache must be registered separately.

Internal infrastructure

  • CacheBoundaryService (singleton) — owns all coordination behind CacheBoundary: cache-key resolution, single-flight stampede protection, store interaction, cached-content deserialization, capture-writer creation and lifecycle, background persistence, hole classification (IsHoleComponent), nested-boundary validation (ThrowIfNestedInsideCapturingBoundary), and the associated logging.
  • CacheBoundaryKeyResolver — computes a deterministic SHA-256 key from tree position, CacheKey, and the active VaryBy* dimension values from HttpContext. All inputs are length-prefixed to prevent delimiter-injection collisions; vary-by name lists are sorted for order independence.
  • ICacheBoundaryStore — pluggable cache backend abstraction. Two implementations ship in this PR; the store is selected once at registration time in AddRazorComponents, based on CacheBoundaryStoreOptions.UseHybridCache:
    • MemoryCacheBoundaryStore (default) — uses MemoryCache with the configurable size limit, plus a per-key ConcurrentDictionary for single-flight coordination.
    • HybridCacheBoundaryStore (used when AddHybridCacheBoundaryStore() was called) — delegates single-flight, stampede protection, local/distributed tiering, and serialization to HybridCache; tags entries so Clear() can evict all CacheBoundary entries in test scenarios.
  • CacheBoundaryStoreOptions — internal options object carrying the UseHybridCache flag set by AddHybridCacheBoundaryStore().
  • CacheStoreOptions — carries expiration and priority settings from the component to the store.
  • CacheBoundaryRenderState — per-render coordination state for a single CacheBoundary instance, spanning the two phases of a cache miss (the render that begins the single-flight, and the later HTML emission that produces and persists the entry).
  • CacheBoundaryTextWriter — a TextWriter decorator that tee-writes to the response and an internal segment buffer, with capture/validation modes for hole creation. On completion, GetJson() serializes captured markup segments interleaved with hole component nodes.
  • EndpointComponentState — assigns a TreePositionKeyFactory to each CacheBoundary instance, producing a key from the parent component type, the sequence number within the parent's render tree, and any @key directive. The sequence disambiguates multiple CacheBoundary siblings under the same parent.
  • RenderFragmentSerializer — shared serializer/deserializer for render tree frames ↔ RenderTreeNode JSON. Updated to capture Sequence, RenderModeName, and the render-mode Prerender flag on component nodes; the deserializer restores render mode via AddComponentRenderMode when replaying cached component nodes.
  • ComponentKeyHelper — shared utility (new file in src/Components/Shared/src/) for serializing @key values (primitives, Guid, DateTimeOffset, DateOnly, TimeOnly). Extracted from PersistentStateValueProviderKeyResolver so it is reused by both persistent state and cache boundary key computation.

Testing

  • Unit tests (src/Components/Endpoints/test/):

    • CacheBoundaryKeyResolverTest — deterministic key generation, VaryBy* dimension isolation, collision resistance between dimensions, delimiter-injection resistance.
    • CacheBoundaryRenderTest — fallback to a fresh render (with warning log) on deserialization failure; cache hit skips ChildContent invocation.
    • CacheBoundaryTextWriterTest — capture/markup/hole segment assembly.
    • IsHoleComponentTest — hole classification with/without the attribute, conditional exclusion with VaryBy flags, Disallow behavior, inherited attribute detection.
    • RenderFragmentSerializerTest — round-trip serialization of markup, components, attributes, render modes, and nested fragments.
  • E2E tests (src/Components/test/E2ETest/Tests/), defined in CacheBoundaryTestBase and run against both stores:

    • CacheBoundaryCachesData — cached content persists across navigations while non-cached content changes.
    • CacheBoundaryDoesNotCacheDataWhenNotEnabledEnabled=false disables caching.
    • CacheBoundaryCorrectlyCreatesHoles[CacheBoundaryPolicy]-marked components remain functional across cache hits.
    • EditFormWithFormComponents_CachesStaticContent_AndFormStillSubmits — form holes stay functional while surrounding static content is cached.
    • CacheBoundaryInLoopUsesVaryByForDistinctEntriesVaryBy produces distinct cache entries in a loop.
    • CacheBoundaryMultipleHolesOfSameType_PreserveCorrectOrder — multiple holes of the same component type within a single CacheBoundary preserve correct order across cache hits.
    • ReusableComponentWithCacheBoundary_UsedTwice_SharesOneCacheEntry — same-request key collision shares one cache entry.
    • CacheBoundaryCachesHardcodedHole — a [CacheBoundaryPolicy] hole hard-coded inside an intermediate component's own markup is cached around (static wrapper output cached, hole re-renders fresh).
    • CacheBoundaryTreatsStreamingChildAsHole — a streaming-rendered child is treated as a hole: surrounding static markup is served from the cache while the streaming child re-renders fresh on every request.
    • CacheBoundaryTest runs these against the default in-memory store; CacheBoundaryHybridCacheTest runs them against the HybridCache-backed store to verify parity.

Limitations

Holes are located in their actual render-tree parent's frames (TryCaptureHoleParameterFrames), so a [CacheBoundaryPolicy] hole is supported regardless of how it reaches the cached subtree — whether it sits directly in the CacheBoundary's ChildContent, is passed through an intermediate component as @ChildContent, or is hard-coded in an intermediate component's own markup/BuildRenderTree. The CacheBoundaryCachesHardcodedHole E2E test exercises the hard-coded case: the wrapper's static output is served from the cache while the hole re-renders fresh on every request.

A CacheBoundary cannot be nested inside another CacheBoundary. The inner boundary's output would be frozen into the outer cache entry and replayed on later requests, which is unsafe for per-request content. This is detected during capture (ThrowIfNestedInsideCapturingBoundary) and throws an actionable InvalidOperationException.

The other case that is deliberately not supported is a hole component with a RenderFragment parameter. A hole is re-rendered on every request, but its parameters are captured once and replayed, so a RenderFragment parameter would be frozen to the content of the first render. Hard-coded hole with multiple RenderFragment parameters is an ambiguous edge case that is hard to define predictably for users, so we cut off the entire "hole with a RenderFragment parameter" case for consistency rather than supporting only some shapes of it. This is detected up front (ThrowIfHoleHasRenderFragmentParameter) and throws an actionable InvalidOperationException instructing the developer to remove the RenderFragment parameter or move the component outside the CacheBoundary.

Streaming

var renderBoundaryMarkers = allowBoundaryMarkers && componentState.StreamRendering;
var captureWriter = output as CacheBoundaryTextWriter;
if (captureWriter is not null && captureWriter.IsCapturing &&
    (CacheBoundaryService.IsHoleComponent(componentState.Component.GetType(), captureWriter.VaryBy) || renderBoundaryMarkers))
{
    captureWriter.PauseCapture();
    ...
    captureWriter.CreateHole(holeComponentType, holeBoundary?.RenderMode, holeCapture, ...);
}

While a CacheBoundary is capturing, every child it writes is inspected, and a streaming-rendered child (componentState.StreamRendering) is treated exactly like a [CacheBoundaryPolicy] hole: capture is paused and the component is recorded as a hole instead of being frozen into the cache entry. Consequently, streaming output is never cached — on every request (cache hit or miss) the hole re-renders live and streams its own updates, while only the static markup surrounding it is served from the cache. This keeps caching safe, because streaming content is inherently asynchronous and per-request and must not be captured into a shared entry.

Note: Output caching of a CacheBoundary that is itself rendered inside a streaming render context (i.e. underneath a [StreamRendering] ancestor) is not supported. In that case EndpointComponentState sets CacheBoundary.IsInStreamingContext, and CacheBoundaryService.PrepareAsync skips caching entirely, so the boundary's content always renders fresh. This only affects boundaries nested within a streaming subtree; a streaming child inside a CacheBoundary is still handled (as a hole, per above).

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 Outdated
Comment thread src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs Outdated
Comment thread src/Components/Endpoints/src/CacheComponent/CacheComponent.cs Outdated

@javiercn javiercn left a comment

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.

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
Comment thread src/Components/Endpoints/src/CacheBoundary/CacheStoreOptions.cs
Comment thread src/Components/Endpoints/src/CacheComponent/CacheComponentKeyResolver.cs Outdated
Comment thread src/Components/Endpoints/src/CacheBoundary/CacheBoundaryStore.cs Outdated
Comment thread src/Components/Endpoints/src/CacheComponent/CacheComponentStore.cs Outdated
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
@dariatiurina

Copy link
Copy Markdown
Contributor Author

To decouple caching and EndpointHtmlRenderer even more, we can create a new ComponentTextWriter that will take all of the HTML creation from renderer and handle it. With that we will be able to extend it and handle all caching and pausing/resuming recording purely from ComponentTextWriter perspective and it will make EndpointHtmlRenderer more readable. However, I decided not to do it in this PR, because it will make it more complex and complicated to read/review, so if we want to proceed, I am willing to do it later in the follow-up PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/Components/Endpoints/src/Rendering/CacheBoundaryTextWriter.cs
Comment thread src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs Outdated
Comment thread src/Components/Endpoints/src/Rendering/EndpointComponentState.cs Outdated
Comment thread src/Components/Components/src/CacheBoundaryPolicyAttribute.cs Outdated
dariatiurina and others added 3 commits June 18, 2026 15:41
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@ilonatommy ilonatommy left a comment

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.

Shallow pass.

Comment thread src/Components/Endpoints/src/CacheBoundary/CacheBoundaryKeyResolver.cs Outdated
Comment thread src/Components/test/E2ETest/Tests/CacheBoundaryTestBase.cs
Comment thread src/Components/Endpoints/src/CacheBoundary/CacheBoundaryService.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/Components/Endpoints/src/CacheBoundary/CacheBoundaryService.cs
Comment thread src/Components/Endpoints/src/Rendering/EndpointComponentState.cs Outdated
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

6 participants