from-cache
" }], + }; + var store = new TestCacheStore { ReturnForAnyKey = JsonSerializer.Serialize(precomputed, ServerComponentSerializationSettings.JsonSerializationOptions) }; + var service = new CacheBoundaryService(store, new TestLoggerFactory(new TestLogger())); + + var childContentInvocations = 0; + var component = new CacheBoundary + { + ChildContent = builder => + { + childContentInvocations++; + builder.AddMarkupContent(0, "from-fresh
"); + }, + CacheService = service, + HttpContext = httpContext, + }; + + var frames = await RenderComponent(component); + + Assert.Equal(0, childContentInvocations); + AssertContainsMarkup(frames, "from-cache
"); + } + + private static void AssertContainsMarkup(ArrayRangebefore
"); + writer.PauseCapture(); + writer.CreateHole(typeof(TestRenderFragmentHole), renderMode: null, capture, NullLogger.Instance); + writer.StartCapture(); + writer.Write("after
"); + writer.StopCapture(); + + var json = writer.GetJson(); + var beforeIndex = json.IndexOf("before", StringComparison.Ordinal); + var holeIndex = json.IndexOf("hole-value", StringComparison.Ordinal); + var afterIndex = json.IndexOf("after", StringComparison.Ordinal); + Assert.True(beforeIndex >= 0 && holeIndex > beforeIndex && afterIndex > holeIndex); + } + + private static RenderTreeFrame[] CaptureFramesFor(RenderFragment fragment) + { + using var builder = new RenderTreeBuilder(); + fragment(builder); + var frames = builder.GetFrames(); + var slice = new RenderTreeFrame[frames.Count]; + Array.Copy(frames.Array, 0, slice, 0, frames.Count); + return slice; + } + + [CacheBoundaryPolicy] + private sealed class TestRenderFragmentHole : IComponent + { + [Parameter] public string? Title { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter] public RenderFragment@Guid.NewGuid()
+@Guid.NewGuid()
+@Guid.NewGuid()
+@Guid.NewGuid()
+@Guid.NewGuid()
+@Guid.NewGuid()
+ @for (var i = 0; i < 2; i++) + { + var index = i; + var label = index == 0 ? "first" : "second"; +@Guid.NewGuid()
+@Guid.NewGuid()
+@SubmittedMessage
+@Guid.NewGuid()
+@(Content ?? Guid.NewGuid().ToString())
+ +@code { + [Parameter] public string? Id { get; set; } + + [Parameter] public string CssClass { get; set; } = ""; + + [Parameter] public string? Content { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/ReusablePanel.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/ReusablePanel.razor new file mode 100644 index 000000000000..10670f728508 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/CacheBoundaryTest/ReusablePanel.razor @@ -0,0 +1,16 @@ +@* A reusable component that internally contains a CacheBoundary with no explicit CacheKey. + Using it more than once on a page produces two CacheBoundary instances that resolve to the + same cache key (same parent type + sequence), with no user error. This must not hang the + request. *@ + +@Label
+@Guid.NewGuid()
+@Guid.NewGuid()
+} + +@code { + [Parameter] public string CssClass { get; set; } = ""; + + private bool _loaded; + + protected override async Task OnInitializedAsync() + { + await Task.Yield(); + _loaded = true; + } +}