CacheBoundary support for Blazor#65772
Conversation
d61f98f to
20c296c
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
20c296c to
ace51a8
Compare
There was a problem hiding this comment.
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/NotCacheComponentplus key computation, JSON segment (“template + holes”) format, and anIMemoryCache-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. |
javiercn
left a comment
There was a problem hiding this comment.
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.
|
To decouple caching and |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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 aCacheBoundaryare 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 internalCacheBoundaryService. TheCacheBoundarycomponent stays focused on rendering, andCacheBoundaryTextWriteron writing.Cache miss path
CacheBoundary.SetParametersAsynccallsCacheBoundaryService.PrepareAsync, which computes a cache key viaCacheBoundaryKeyResolverfrom the component's tree position,CacheKey, and theVaryBy*dimension values read from the currentHttpContext, then callsICacheBoundaryStore.GetOrCreateAsync. Single-flight coordination ensures concurrent requests for the same key share one factory invocation. Caching is skipped entirely for non-GET requests, whenEnabledisfalse, and when the boundary is rendered inside a streaming render context.EndpointHtmlRenderer.WriteComponentHtmlwraps the outputTextWriterin aCacheBoundaryTextWriter(viaCacheBoundaryService.TryBeginWrite) that tee-writes to both the response and an internal segment buffer.CacheBoundaryService.EndCapturefinalizes the capture andCacheBoundaryTextWriter.GetJson()serializes the captured segments — markup segments plus hole component nodes (serialized viaRenderFragmentSerializer.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
CacheBoundaryService.PrepareAsyncreturns the cached JSON, whichCacheBoundary.BuildRenderTreerenders.RenderTreeNodeobjects (markup nodes, component nodes, attributes, and nestedRenderFragmentparameters).RenderFragmentSerializer.Deserializeconverts the nodes back into aRenderFragmentthat emits render tree frames directly into the builder — no child component instances are created and no lifecycle methods fire.Same-request key collisions
Multiple
CacheBoundaryinstances in one request can resolve to the same key (e.g. a reusable component containing aCacheBoundaryused more than once, or a loop without an explicitCacheKey). 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 inHttpContext.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 inCacheBoundaryService.IsHoleComponent(Type, CacheBoundaryVaryBy)(called fromEndpointHtmlRendererduring capture):[CacheBoundaryPolicy](lookup is cached per-type in aConcurrentDictionarythat is cleared on hot-reload).VaryBy = None→ unconditional hole (e.g.AntiforgeryToken,HeadOutlet,SSRRenderModeBoundary).VaryByflags set → conditional hole. The component is a hole only when the enclosingCacheBoundary's activeVaryBydimensions do not cover all of the attribute's required dimensions.AuthorizeViewCoreis[CacheBoundaryPolicy(Disallow = true, VaryBy = CacheBoundaryVaryBy.User)]. If the boundary setsVaryByUser="true", the per-user dimension is in the cache key, soAuthorizeViewis safe to cache and is not a hole. Otherwise it throws.Disallow = trueand the component would be a hole → anInvalidOperationExceptionis 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.EndpointComponentState.StreamRenderingistruealso force a hole (streaming children can't update past a cached parent).Built-in components marked with
[CacheBoundaryPolicy]AuthorizeViewCore[CacheBoundaryPolicy(Disallow = true, VaryBy = User)]VaryByUseris active on the boundaryQuickGrid[CacheBoundaryPolicy(Disallow = true, VaryBy = Query)]VaryByQueryis active on the boundaryVirtualize[CacheBoundaryPolicy(Disallow = true)]AntiforgeryToken[CacheBoundaryPolicy]HeadOutlet[CacheBoundaryPolicy]SSRRenderModeBoundary[CacheBoundaryPolicy]Choosing the cache store
By default
CacheBoundaryuses an in-memory store (MemoryCacheBoundaryStore). Calling the newAddHybridCacheBoundaryStore()extension onIRazorComponentsBuilderswitches it to aHybridCache-backed store (HybridCacheBoundaryStore);HybridCachemust be registered separately. The selection is additive and independent of service registration order — it is recorded on the internalCacheBoundaryStoreOptions.UseHybridCacheflag and the concreteICacheBoundaryStoreis resolved once inAddRazorComponents. With the HybridCache-backed store,ExpiresSlidingandPriorityare unsupported and throwNotSupportedExceptionrather 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 cacheCacheKey— explicit key for disambiguation when multipleCacheBoundaryinstances share the same parentEnabled— toggle caching on/off (defaulttrue)ExpiresAfter/ExpiresOn/ExpiresSliding— expiration policies (ExpiresSlidingis not supported by the HybridCache-backed store)Priority—CacheItemPriorityfor 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 identityVaryByCulture— vary by current culture/UI cultureVaryBy— arbitrary custom string to vary byCacheBoundaryPolicyAttribute(Microsoft.AspNetCore.Components) — controls how a component interacts with an enclosingCacheBoundary. When present, the component becomes a hole in the cached output. Properties:Disallow— whentrue, throwsInvalidOperationExceptioninstead of creating a hole (for components with non-serializable parameters). Defaults tofalse.VaryBy—CacheBoundaryVaryByflags that lift the exclusion when the boundary varies by those dimensionsCacheBoundaryVaryBy(Microsoft.AspNetCore.Components) —[Flags]enum:None,Query,Route,Header,Cookie,User,CultureRazorComponentsServiceOptions.CacheBoundarySizeLimit— configurable maximum size (bytes) for the in-memory cache. Default 100 MB;0configures a zero-byte limit so entries are not cached. Negative values throwArgumentOutOfRangeException.HybridCacheBoundaryStoreServiceCollectionExtensions.AddHybridCacheBoundaryStore(this IRazorComponentsBuilder)(Microsoft.Extensions.DependencyInjection) — opt in to aHybridCache-backedCacheBoundarystore instead of the default in-memory store.HybridCachemust be registered separately.Internal infrastructure
CacheBoundaryService(singleton) — owns all coordination behindCacheBoundary: 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 activeVaryBy*dimension values fromHttpContext. 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 inAddRazorComponents, based onCacheBoundaryStoreOptions.UseHybridCache:MemoryCacheBoundaryStore(default) — usesMemoryCachewith the configurable size limit, plus a per-keyConcurrentDictionaryfor single-flight coordination.HybridCacheBoundaryStore(used whenAddHybridCacheBoundaryStore()was called) — delegates single-flight, stampede protection, local/distributed tiering, and serialization toHybridCache; tags entries soClear()can evict all CacheBoundary entries in test scenarios.CacheBoundaryStoreOptions— internal options object carrying theUseHybridCacheflag set byAddHybridCacheBoundaryStore().CacheStoreOptions— carries expiration and priority settings from the component to the store.CacheBoundaryRenderState— per-render coordination state for a singleCacheBoundaryinstance, 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— aTextWriterdecorator 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 aTreePositionKeyFactoryto eachCacheBoundaryinstance, producing a key from the parent component type, the sequence number within the parent's render tree, and any@keydirective. The sequence disambiguates multipleCacheBoundarysiblings under the same parent.RenderFragmentSerializer— shared serializer/deserializer for render tree frames ↔RenderTreeNodeJSON. Updated to captureSequence,RenderModeName, and the render-modePrerenderflag on component nodes; the deserializer restores render mode viaAddComponentRenderModewhen replaying cached component nodes.ComponentKeyHelper— shared utility (new file insrc/Components/Shared/src/) for serializing@keyvalues (primitives,Guid,DateTimeOffset,DateOnly,TimeOnly). Extracted fromPersistentStateValueProviderKeyResolverso 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 skipsChildContentinvocation.CacheBoundaryTextWriterTest— capture/markup/hole segment assembly.IsHoleComponentTest— hole classification with/without the attribute, conditional exclusion withVaryByflags,Disallowbehavior, inherited attribute detection.RenderFragmentSerializerTest— round-trip serialization of markup, components, attributes, render modes, and nested fragments.E2E tests (
src/Components/test/E2ETest/Tests/), defined inCacheBoundaryTestBaseand run against both stores:CacheBoundaryCachesData— cached content persists across navigations while non-cached content changes.CacheBoundaryDoesNotCacheDataWhenNotEnabled—Enabled=falsedisables caching.CacheBoundaryCorrectlyCreatesHoles—[CacheBoundaryPolicy]-marked components remain functional across cache hits.EditFormWithFormComponents_CachesStaticContent_AndFormStillSubmits— form holes stay functional while surrounding static content is cached.CacheBoundaryInLoopUsesVaryByForDistinctEntries—VaryByproduces distinct cache entries in a loop.CacheBoundaryMultipleHolesOfSameType_PreserveCorrectOrder— multiple holes of the same component type within a singleCacheBoundarypreserve 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.CacheBoundaryTestruns these against the default in-memory store;CacheBoundaryHybridCacheTestruns them against theHybridCache-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 theCacheBoundary'sChildContent, is passed through an intermediate component as@ChildContent, or is hard-coded in an intermediate component's own markup/BuildRenderTree. TheCacheBoundaryCachesHardcodedHoleE2E 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
CacheBoundarycannot be nested inside anotherCacheBoundary. 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 actionableInvalidOperationException.The other case that is deliberately not supported is a hole component with a
RenderFragmentparameter. A hole is re-rendered on every request, but its parameters are captured once and replayed, so aRenderFragmentparameter would be frozen to the content of the first render. Hard-coded hole with multipleRenderFragmentparameters is an ambiguous edge case that is hard to define predictably for users, so we cut off the entire "hole with aRenderFragmentparameter" case for consistency rather than supporting only some shapes of it. This is detected up front (ThrowIfHoleHasRenderFragmentParameter) and throws an actionableInvalidOperationExceptioninstructing the developer to remove theRenderFragmentparameter or move the component outside theCacheBoundary.Streaming
While a
CacheBoundaryis 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.Fixes #55520
Fixes #65756