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.
|
The three main things that I see we should address are the duplicated stampede support, the public API, and the |
2cc3c35 to
b55be22
Compare
| private static readonly ComponentParametersTypeCache _parametersTypeCache = new(); | ||
| private static readonly ConcurrentDictionary<Type, CacheBoundaryPolicyAttribute?> _policyByComponentType = new(); |
There was a problem hiding this comment.
Since the service is a singleton, is there a reason to split between "static" and "instance" members?
javiercn
left a comment
There was a problem hiding this comment.
LGTM! Thanks for pushing this through!
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 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.GetSerializedRenderFragment()assembles the captured segments — markup segments plus hole component nodes (serialized viaRenderFragmentSerializer.SerializeFrames) — into aSerializedRenderFragmentobject. That object (not a pre-serializedbyte[]) is handed back to the store, which persists it: theHybridCache-backed store serializes it through the registeredIHybridCacheSerializer<SerializedRenderFragment>, while the in-memory store keeps the object as-is. Persistence is observed in the background; failures are logged and do not fail the render.Cache hit path
CacheBoundaryService.PrepareAsyncreturns the cachedSerializedRenderFragment(already deserialized by the store's serializer when coming fromHybridCache, or the live object from the in-memory store), 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 in-flight creator resolutions are 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.IsCacheableComponent(Type, CacheBoundaryVaryBy)(called fromEndpointHtmlRendererduring capture). The method returnstruewhen the component can be baked into the cached HTML andfalsewhen it must be treated as a hole:[CacheBoundaryPolicy](lookup is cached per-type in aConcurrentDictionarythat is cleared on hot-reload).true); the component is baked into cached HTML.VaryBy = None→ unconditional hole (returnsfalse, e.g.AntiforgeryToken,HeadOutlet,SSRRenderModeBoundary).VaryByflags set → conditional hole. The component is cacheable only when the enclosingCacheBoundary's activeVaryBydimensions cover all of the attribute's required dimensions; otherwise it is a hole.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. 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). The store selection follows the same pattern as the circuit persistence provider (ICircuitPersistenceProvider): when aHybridCacheservice is registered, theHybridCache-backed store (HybridCacheBoundaryStore) is selected automatically; otherwise the in-memory store is used.The mechanism is overwrite-safe and order-independent:
RazorComponentsServiceOptions.CacheBoundaryHybridCacheis the single source of truth for whichHybridCacheto use.DefaultCacheBoundaryHybridCache(anIPostConfigureOptions<RazorComponentsServiceOptions>with an optionalHybridCache? = nullconstructor parameter) populates that option from the registeredHybridCacheonly when it has not been explicitly set, so an explicit value is never overwritten. This mirrorsDefaultHybridCachefor circuit options.ICacheBoundaryStoreis resolved lazily fromIOptions<RazorComponentsServiceOptions>: option set →HybridCacheBoundaryStore, otherwiseMemoryCacheBoundaryStore.An application can therefore opt in simply by calling
AddHybridCache(), or point boundaries at a separately-configuredHybridCacheinstance by settingCacheBoundaryHybridCacheexplicitly. With the HybridCache-backed store,ExpiresSlidingis unsupported and throwsNotSupportedExceptionrather than being silently mapped to a different concept; useExpiresAfterorExpiresOninstead.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)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.RazorComponentsServiceOptions.CacheBoundaryHybridCache— theHybridCacheinstance used byCacheBoundary. When left unset it is populated automatically from the registeredHybridCacheservice if one is available; otherwise the in-memory store is used. Set it explicitly to use a separately-configuredHybridCache.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 (IsCacheableComponent), 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. ItsGetOrCreateAsyncoperates onSerializedRenderFragmentobjects (notbyte[]), leaving serialization to the store implementation. Two implementations ship in this PR; the store is resolved lazily based onRazorComponentsServiceOptions.CacheBoundaryHybridCache:MemoryCacheBoundaryStore(default) — usesMemoryCachewith the configurable size limit, plus a per-keyConcurrentDictionaryfor single-flight coordination. It stores theSerializedRenderFragmentobject directly and sizes each entry fromSerializedRenderFragment.ContentSize(captured markup length plus a per-hole overhead) instead of a serialized byte length.HybridCacheBoundaryStore(used when aHybridCacheis available) — delegates single-flight, stampede protection, local/distributed tiering, and serialization toHybridCache; tags entries soClear()can evict all CacheBoundary entries on hot reload.SerializedRenderFragmentHybridCacheSerializer—IHybridCacheSerializer<SerializedRenderFragment>registered soHybridCacheserializes cached fragments with the sameSystem.Text.Jsonoptions as the rest of the component serialization pipeline (ServerComponentSerializationSettings.JsonSerializationOptions).DefaultCacheBoundaryHybridCache—IPostConfigureOptions<RazorComponentsServiceOptions>that auto-populatesCacheBoundaryHybridCachefrom a registeredHybridCacheonly when unset (mirrors circuit persistence'sDefaultHybridCache).CacheStoreOptions— carries the expiration settings (ExpiresAfter,ExpiresOn,ExpiresSliding) 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,GetSerializedRenderFragment()assembles captured markup segments interleaved with hole component nodes into aSerializedRenderFragment, tracking the total captured size for cache accounting.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.SerializedRenderFragmentalso carries a[JsonIgnore]ContentSizeused for in-memory cache sizing.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 toChildContentwhen the cached fragment is empty; cache hit skipsChildContentinvocation; two distinct boundaries resolving to the same key throw an actionable error; the same boundary instance prepared twice reuses its in-flight resolution.CacheBoundaryTextWriterTest— capture/markup/hole segment assembly.IsCacheableComponentTest— component classification with/without the attribute, conditional exclusion withVaryByflags,Disallowbehavior, inherited attribute detection.MemoryCacheBoundaryStoreTest/HybridCacheBoundaryStoreTest— store-level get-or-create, single-flight, and expiration behavior.RazorComponentsServiceOptionsTest—CacheBoundarySizeLimitvalidation.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.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.CacheBoundaryMemoryCacheTestruns 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, 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