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.
|
|
||
| namespace Microsoft.AspNetCore.Components.Endpoints; | ||
|
|
||
| internal readonly struct CacheStoreOptions |
There was a problem hiding this comment.
Are these options lifted from the MVC implementation?
| /// <summary> | ||
| /// Provides a store for caching rendered component output as a JSON template-with-holes representation. | ||
| /// </summary> | ||
| internal abstract class CacheComponentStore : IDisposable |
There was a problem hiding this comment.
If there is no shared logic at all here, it might as well be an interface
There was a problem hiding this comment.
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
There was a problem hiding this comment.
HybridCache was discussed in the design doc. The main problem is sync lifecycle of CacheBoundary.
| cacheBoundary.TreePositionKeyFactory = () => | ||
| { | ||
| var sequence = FindSequenceInParent(parentComponentState, cacheBoundary); | ||
| var componentKey = GetComponentKey(); | ||
| var keyString = ComponentKeyHelper.FormatSerializableKey(componentKey); | ||
| return ComputeTreePositionKey(ancestorTypeName, sequence, keyString); |
There was a problem hiding this comment.
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> |
Question is still open with support for We can do manual layer that will adapt our system of cache with |
| } | ||
|
|
||
| private long _cacheBoundarySizeLimit = 100_000_000; | ||
| internal static readonly TimeSpan DefaultCacheBoundaryExpiration = TimeSpan.FromSeconds(30); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
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 aCacheBoundaryare 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
CacheBoundary.BuildRenderTreecomputes a cache key viaCacheBoundaryKeyResolverusing tree position,CacheKey, andVaryBy*dimension values from the currentHttpContext.EndpointHtmlRenderer.WriteComponentHtmlwraps the outputTextWriterin aCacheBoundaryTextWriterthat tee-writes to both the response and an internal segment buffer.CacheBoundaryTextWriter.GetJson()serializes the captured entries — matching hole entries against the serializedChildContentrender tree (viaRenderFragmentSerializer.SerializeFrames) — and stores the result in the cache.Cache Hit Path
CacheBoundary.BuildRenderTreeretrieves the cached JSON from the store.RenderTreeNodeobjects (a component tree representation with 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.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 inEndpointHtmlRenderer.IsHoleComponent(Type, CacheBoundaryVaryBy):[CacheBoundaryPolicy](lookup is cached per-type in aConcurrentDictionary).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(Throw = 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.Throw = trueand the component would be a hole → anInvalidOperationExceptionis 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.EndpointComponentState.StreamRenderingistruealso force a hole (streaming children can't update past a cached parent).Built-in components marked with
[CacheBoundaryPolicy]AuthorizeViewCore[CacheBoundaryPolicy(Throw = true, VaryBy = User)]VaryByUseris active on the boundaryEditForm[CacheBoundaryPolicy(Throw = true)]InputBase<T>[CacheBoundaryPolicy(Throw = true)]ValidationMessage<T>[CacheBoundaryPolicy(Throw = true)]ValidationSummary[CacheBoundaryPolicy(Throw = true)]DisplayName<T>[CacheBoundaryPolicy(Throw = true)]Label<T>[CacheBoundaryPolicy(Throw = true)]Virtualize<T>[CacheBoundaryPolicy(Throw = true)]QuickGrid<TGridItem>[CacheBoundaryPolicy(Throw = true, VaryBy = Query)]VaryByQueryis active on the boundaryAntiforgeryToken[CacheBoundaryPolicy]HeadOutlet[CacheBoundaryPolicy]SSRRenderModeBoundary[CacheBoundaryPolicy]New Public API
CacheBoundary(Microsoft.AspNetCore.Components.CacheBoundary) — 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 policiesPriority—CacheItemPriorityfor the cache entryVaryByQuery,VaryByRoute,VaryByHeader,VaryByCookie— vary cache by comma-separated request dimension namesVaryByUser— vary by authenticated identityVaryByCulture— vary by current culture/UI cultureVaryBy— arbitrary custom string to vary byCacheBoundaryPolicyAttribute(Microsoft.AspNetCore.Components.CacheBoundaryPolicyAttribute) — attribute controlling how a component interacts with an enclosingCacheBoundary. When present, the component becomes a "hole" in the cached output. Properties:Throw— whentrue, throwsInvalidOperationExceptioninstead of creating a hole (for components with non-serializable parameters)VaryBy—CacheBoundaryVaryByflags that lift the exclusion when the boundary varies by those dimensionsCacheBoundaryVaryBy(Microsoft.AspNetCore.Components.CacheBoundaryVaryBy) —[Flags]enum:None,Query,Route,Header,Cookie,User,CultureRazorComponentsServiceOptions.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 allVaryBy*dimension values from the currentHttpContext.ICacheBoundaryStore/MemoryCacheBoundaryStore— pluggable cache backend; default usesMemoryCachewith a configurable size limit. Registered as a singleton inAddRazorComponents.CacheStoreOptions— passes expiration and priority settings from the component to the store.CacheBoundaryTextWriter— aTextWriterdecorator 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 byRenderFragmentSerializer.SerializeFrames.EndpointComponentState— assigns aTreePositionKeyFactoryto eachCacheBoundaryinstance, producing a key from the parent component type,CacheBoundarytype name, the sequence number within the parent's render tree, and any@keydirective.RenderFragmentSerializer— shared serializer/deserializer for render tree frames →RenderTreeNodeJSON representation. Updated to captureSequenceandRenderModeNameon component nodes (previouslyComponentRenderModeframes were skipped during serialization). The deserializer now restores render mode viaAddComponentRenderModewhen replaying cached component nodes.ComponentKeyHelper— shared utility (new file insrc/Components/Shared/src/) for serializing@keyvalues (supports primitive types,Guid,DateTimeOffset,DateOnly,TimeOnly). Extracted fromPersistentStateValueProviderKeyResolverso 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 resistanceCacheBoundaryRenderTest— fallback to fresh render on deserialization failure with warning log, cache hit skipping ChildContent invocationIsHoleComponentTest— hole classification for components with/without the attribute, conditional exclusion with VaryBy flags,Throwbehavior, inherited attribute detectionE2E tests (
CacheBoundaryTestinsrc/Components/test/E2ETest/Tests/):Enabled=falsedisables caching[CacheBoundaryPolicy]-marked components) remain functional across cache hitsCacheBoundarydoes not re-execute inner component on outer cache hitCacheKeyenables distinct cache entries in loopsCacheBoundarypreserve correct order across cache hitsFixes #55520
Fixes #65756