diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index fff6e6e7ba1e..2cbf38ec9f76 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -72,6 +72,46 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac spacerAfter.style.display = 'table-row'; } + // Applies one-time base style (flex-shrink) on first sight of an element. + const baseStylesAppliedProp = Symbol(); + function ensureBaseStyles(el: HTMLElement): void { + if ((el as any)[baseStylesAppliedProp]) { + return; + } + (el as any)[baseStylesAppliedProp] = true; + el.style.flexShrink = '0'; + } + + const layoutAttrs = [ + ['data-blazor-virtualize-reserved-height', 'height', (n: number) => `${n}px`], + ['data-blazor-virtualize-loop-breaker-transform', 'transform', (n: number) => `translateY(${n}px)`], + ] as const; + const layoutAttrNames = layoutAttrs.map(([a]) => a); + function applyLayoutAttrs(el: HTMLElement): void { + ensureBaseStyles(el); + for (const [attr, styleProp, format] of layoutAttrs) { + const raw = el.getAttribute(attr); + const n = raw ? Number(raw) : NaN; + if (Number.isFinite(n)) { + el.style.setProperty(styleProp, format(n)); + } else { + el.style.removeProperty(styleProp); + } + } + } + + // Apply layout attributes before the MutationObserver starts catching changes. + function applyLayoutAttrsBetweenSpacers(): void { + for (let el: Element | null = spacerBefore; + el && el !== spacerAfter.nextElementSibling; + el = el.nextElementSibling) { + if (layoutAttrNames.some(a => el!.hasAttribute(a))) { + applyLayoutAttrs(el as HTMLElement); + } + } + } + applyLayoutAttrsBetweenSpacers(); + if (useNativeAnchoring) { // Prevent spacers from being used as scroll anchors — only rendered items should anchor. spacerBefore.style.overflowAnchor = 'none'; @@ -81,6 +121,22 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement.style.overflowAnchor = 'none'; } + // Observe only the two spacers we already hold references to. Placeholders are siblings between them, + // so on each spacer mutation we walk the sibling chain to reapply styles. + const mutationObserver = new MutationObserver(applyLayoutAttrsBetweenSpacers); + + function flushPendingStyleMutations(): void { + if (mutationObserver.takeRecords().length > 0) { + applyLayoutAttrsBetweenSpacers(); + } + } + const spacerObserverOptions: MutationObserverInit = { + attributes: true, + attributeFilter: layoutAttrNames, + }; + mutationObserver.observe(spacerBefore, spacerObserverOptions); + mutationObserver.observe(spacerAfter, spacerObserverOptions); + const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, rootMargin: `${rootMargin}px`, @@ -196,17 +252,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.observe(spacerAfter); function refreshObservedElements(): void { - // C# style updates overwrite the entire style attribute. Re-apply what we need. - if (isTable) { - spacerBefore.style.display = 'table-row'; - spacerAfter.style.display = 'table-row'; - } - - if (useNativeAnchoring) { - spacerBefore.style.overflowAnchor = 'none'; - spacerAfter.style.overflowAnchor = 'none'; - } - // Ensure spacers are always observed (idempotent). resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); @@ -293,6 +338,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // Corrects scrollTop after a render that shifted content, using the snapshot // saved by updateAnchorSnapshot() during the previous render cycle. function restoreAnchorForShift(): void { + // Apply styles before we read layout + flushPendingStyleMutations(); + const snapshot = observersByDotNetObjectId[id].anchorSnapshot; if (!snapshot) { return; @@ -504,6 +552,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac beginProgrammaticScroll: beginProgrammaticScrollSuppression, anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null, onDispose: () => { + mutationObserver.disconnect(); stopConvergenceObserving(); anchoredItems.clear(); resizeObserver.disconnect(); diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 93d8f759e5cd..1385f3426fe5 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -520,7 +520,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } builder.OpenElement(0, SpacerElement); - builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore)); + builder.AddAttribute(1, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(_itemsBefore)); builder.AddAttribute(2, "aria-hidden", "true"); builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference); builder.CloseElement(); @@ -589,22 +589,18 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(7, SpacerElement); builder.AddAttribute(8, "aria-hidden", "true"); - builder.AddAttribute(9, "style", GetSpacerStyle(itemsAfter, _unusedItemCapacity)); - builder.AddElementReferenceCapture(10, elementReference => _spacerAfter = elementReference); + builder.AddAttribute(9, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(itemsAfter)); + if (_unusedItemCapacity != 0) + { + builder.AddAttribute(10, "data-blazor-virtualize-loop-breaker-transform", GetSpacerHeightPx(_unusedItemCapacity)); + } + builder.AddElementReferenceCapture(11, elementReference => _spacerAfter = elementReference); builder.CloseElement(); } - private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove) - { - var avgHeight = GetItemHeight(); - return numItemsGapAbove == 0 - ? GetSpacerStyle(itemsInSpacer) - : $"height: {(itemsInSpacer * avgHeight).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * avgHeight).ToString(CultureInfo.InvariantCulture)}px);"; - } - - private string GetSpacerStyle(int itemsInSpacer) - => $"height: {(itemsInSpacer * GetItemHeight()).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"; + private string GetSpacerHeightPx(int itemCount) + => (itemCount * GetItemHeight()).ToString(CultureInfo.InvariantCulture); private float GetItemHeight() => _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize; @@ -938,7 +934,7 @@ private ValueTask> DefaultItemsProvider(ItemsProvider private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builder) => { builder.OpenElement(0, "div"); - builder.AddAttribute(1, "style", $"height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"); + builder.AddAttribute(1, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(1)); builder.CloseElement(); }; diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 11db48e76e58..6d231a678e62 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -1215,4 +1216,48 @@ public async Task InitialIndex_BeyondCountClampsToEnd() // capacity = OverscanCount*2 + 1 = 31 with default OverscanCount=15, so _itemsBefore = max(0, 100 - 31) = 69. Assert.Equal(69, renderedVirtualize._itemsBefore); } + + [Fact] + public async Task Virtualize_SpacersUseDataAttributeNotInlineStyle() + { + Virtualize renderedVirtualize = null; + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + captureRenderedVirtualize: v => renderedVirtualize = v) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + + await testRenderer.RenderRootComponentAsync(componentId); + Assert.NotNull(renderedVirtualize); + + // Spacer elements use data-blazor-virtualize-* attributes instead of inline style. + // A MutationObserver on the JS side mirrors them to element styles via CSSOM. + var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); + + var heightAttributes = referenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Attribute + && f.AttributeName == "data-blazor-virtualize-reserved-height") + .ToList(); + + Assert.Equal(2, heightAttributes.Count); + Assert.Equal("0", (string)heightAttributes[0].AttributeValue); + Assert.True(double.TryParse((string)heightAttributes[1].AttributeValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _)); + + var inlineStyleAttributes = referenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Attribute + && f.AttributeName == "style") + .ToList(); + + // The only inline style is the test host's scroll container; Virtualize itself emits none. + var hostStyle = Assert.Single(inlineStyleAttributes); + Assert.Equal("overflow: auto; height: 800px;", (string)hostStyle.AttributeValue); + } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/VirtualizationCspTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/VirtualizationCspTest.cs new file mode 100644 index 000000000000..351972f3fc07 --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/VirtualizationCspTest.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests; + +public class VirtualizationCspTest : ServerTestBase> +{ + public VirtualizationCspTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + [Fact] + public void Virtualize_WithItems_DoesNotViolate_StrictStyleCspPolicy() + { + // strict-style-csp causes ServerStartup to add `Content-Security-Policy: style-src 'self'`. + Navigate($"{ServerPathBase}?strict-style-csp=true"); + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("csp-container")); + Browser.True(() => container.FindElements(By.CssSelector(".csp-item")).Count > 0); + + // Scroll to force spacer style updates which set CSS custom properties via CSSOM. + var js = (IJavaScriptExecutor)Browser; + js.ExecuteScript("document.getElementById('csp-container').scrollTop = 5000;"); + + // Allow the MutationObserver microtask + CSSOM updates to flush. + Browser.True(() => + { + var topSpacer = container.FindElements(By.CssSelector(":scope > div"))[0]; + var height = topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height"); + return height != null && height != "0"; + }); + + AssertNoStyleCspViolations(); + } + + [Fact] + public void Virtualize_WithItemsProvider_DoesNotViolate_StrictStyleCspPolicy() + { + // strict-style-csp causes ServerStartup to add `Content-Security-Policy: style-src 'self'`. + Navigate($"{ServerPathBase}?strict-style-csp=true&mode=items-provider"); + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("csp-container-async")); + + // Wait for either placeholders (during in-flight load) or resolved items. + Browser.True(() => container.FindElements( + By.CssSelector(".csp-placeholder, .csp-item-async")).Count > 0); + + // Scroll to trigger new placeholder renders and spacer style updates. + var js = (IJavaScriptExecutor)Browser; + js.ExecuteScript("document.getElementById('csp-container-async').scrollTop = 5000;"); + + // Wait for resolved items after scroll so the placeholder path has executed. + Browser.True(() => container.FindElements(By.CssSelector(".csp-item-async")).Count > 0); + + AssertNoStyleCspViolations(); + } + + [Theory] + [InlineData("url(https://example.invalid/x.png)")] // non-numeric, CSS-like + [InlineData("120px")] // a unit suffix is no longer accepted + [InlineData("abc")] // non-numeric + [InlineData("1e9999")] // overflows to Infinity (not finite) + [InlineData("NaN")] // non-finite + [InlineData("1 2")] // multi-token + [InlineData("")] // empty + public void Virtualize_RejectsUnexpectedLayoutAttributeValues(string invalidValue) + { + // Values that don't parse as a finite number must not propagate to CSS custom properties. + Navigate($"{ServerPathBase}?strict-style-csp=true"); + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("csp-container")); + Browser.True(() => container.FindElements(By.CssSelector(".csp-item")).Count > 0); + + var js = (IJavaScriptExecutor)Browser; + const string spacerSelector = "#csp-container > div:first-of-type"; + + js.ExecuteScript( + "var el = document.querySelector(arguments[0]);" + + "el.setAttribute('data-blazor-virtualize-reserved-height', arguments[1]);" + + "el.setAttribute('data-blazor-virtualize-loop-breaker-transform', arguments[1]);", + spacerSelector, invalidValue); + + // Invalid values must result in the inline style being removed (empty). + Browser.True(() => + { + var heightStyle = (string)js.ExecuteScript( + "return document.querySelector(arguments[0]).style.height;", + spacerSelector); + var transformStyle = (string)js.ExecuteScript( + "return document.querySelector(arguments[0]).style.transform;", + spacerSelector); + return heightStyle == "" && transformStyle == ""; + }); + + AssertNoStyleCspViolations(); + } + + [Fact] + public void QuickGrid_WithVirtualize_DoesNotViolate_StrictStyleCspPolicy() + { + // QuickGrid uses Virtualize internally. Validates the integration is CSP-clean end-to-end. + Navigate($"{ServerPathBase}?strict-style-csp=true"); + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("csp-quickgrid")); + + // Wait for QuickGrid to render at least one data row. + Browser.True(() => container.FindElements(By.CssSelector("tbody tr")).Count > 0); + + // Scroll to force spacer style updates which set CSS custom properties via CSSOM. + var js = (IJavaScriptExecutor)Browser; + js.ExecuteScript("document.getElementById('csp-quickgrid').scrollTop = 20000;"); + + // Wait for the top spacer's reserved-height attribute to grow past 0 after scrolling. + Browser.True(() => + { + var spacers = container.FindElements(By.CssSelector("[data-blazor-virtualize-reserved-height]")); + return spacers.Count > 0 + && spacers[0].GetDomAttribute("data-blazor-virtualize-reserved-height") != "0"; + }); + + AssertNoStyleCspViolations(); + } + + private void AssertNoStyleCspViolations() + { + const string cspErrorMessage = "violates the following Content Security Policy directive: \"style-src"; + var logs = Browser.Manage().Logs.GetLog(LogType.Browser); + var styleErrors = logs.Where(log => log.Message.Contains(cspErrorMessage)).ToList(); + Assert.Empty(styleErrors); + } +} diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 0370730e5ce5..08d3c8fda562 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -41,13 +41,13 @@ public void AlwaysFillsVisibleCapacity_Sync() { Browser.MountTestComponent(); var topSpacer = Browser.Exists(By.Id("sync-container")).FindElement(By.TagName("div")); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; + var expectedInitialSpacerHeight = "0"; int initialItemCount = 0; // Wait until items have been rendered. Browser.True(() => (initialItemCount = GetItemCount()) > 0); - Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.Equal(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); // Scroll halfway. @@ -55,7 +55,7 @@ public void AlwaysFillsVisibleCapacity_Sync() // Validate that we get the same item count after scrolling halfway. Browser.Equal(initialItemCount, GetItemCount); - Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.NotEqual(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); // Scroll to the bottom. @@ -63,7 +63,7 @@ public void AlwaysFillsVisibleCapacity_Sync() // Validate that we get the same item count after scrolling to the bottom. Browser.Equal(initialItemCount, GetItemCount); - Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.NotEqual(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); int GetItemCount() => Browser.FindElements(By.Id("sync-item")).Count; @@ -204,13 +204,13 @@ public virtual void CancelsOutdatedRefreshes_Async() public void CanUseViewportAsContainer() { Browser.MountTestComponent(); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; + var expectedInitialSpacerHeight = "0"; var topSpacer = Browser.Exists(By.Id("viewport-as-root")).FindElement(By.TagName("div")); Browser.ExecuteJavaScript("const element = document.getElementById('viewport-as-root'); element.scrollIntoView();"); // Validate that the top spacer has a height of zero. - Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.Equal(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); Browser.ExecuteJavaScript("window.scrollTo(0, document.body.scrollHeight);"); @@ -219,7 +219,7 @@ public void CanUseViewportAsContainer() Browser.True(() => Browser.Exists(By.Id("999")).Displayed); // Validate that the top spacer has expanded. - Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.NotEqual(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); } @@ -228,11 +228,11 @@ public async Task ToleratesIncorrectItemSize() { Browser.MountTestComponent(); var topSpacer = Browser.Exists(By.Id("incorrect-size-container")).FindElement(By.TagName("div")); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; + var expectedInitialSpacerHeight = "0"; // Wait until items have been rendered. Browser.True(() => GetItemCount() > 0); - Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.Equal(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); // Scroll slowly, in increments of 50px at a time. At one point this would trigger a bug @@ -246,7 +246,7 @@ public async Task ToleratesIncorrectItemSize() } // Validate that the top spacer did change - Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); + Browser.NotEqual(expectedInitialSpacerHeight, () => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); int GetItemCount() => Browser.FindElements(By.ClassName("incorrect-size-item")).Count; @@ -256,14 +256,14 @@ public async Task ToleratesIncorrectItemSize() public virtual void CanRenderHtmlTable() { Browser.MountTestComponent(); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerHeight = "0"; var topSpacer = Browser.Exists(By.CssSelector("#virtualized-table > tbody > :first-child")); var bottomSpacer = Browser.Exists(By.CssSelector("#virtualized-table > tbody > :last-child")); // We can override the tag name of the spacer Assert.Equal("tr", topSpacer.TagName.ToLowerInvariant()); Assert.Equal("tr", bottomSpacer.TagName.ToLowerInvariant()); - Browser.True(() => topSpacer.GetDomAttribute("style").Contains(expectedInitialSpacerStyle)); + Browser.True(() => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height") == expectedInitialSpacerHeight); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); Assert.Contains("true", bottomSpacer.GetDomAttribute("aria-hidden")); @@ -273,8 +273,8 @@ public virtual void CanRenderHtmlTable() Browser.True(() => Browser.Exists(By.Id("row-999")).Displayed); // Validate that the top spacer has expanded, and bottom one has collapsed - Browser.False(() => topSpacer.GetDomAttribute("style").Contains(expectedInitialSpacerStyle)); - Assert.Contains(expectedInitialSpacerStyle, bottomSpacer.GetDomAttribute("style")); + Browser.False(() => topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height") == expectedInitialSpacerHeight); + Assert.Equal(expectedInitialSpacerHeight, bottomSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height")); } [Fact] diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index d0d4c1e88176..a1e71d14a331 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -102,6 +102,7 @@ + @@ -143,6 +144,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridCsp.razor b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridCsp.razor new file mode 100644 index 000000000000..bcbe7508b570 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridCsp.razor @@ -0,0 +1,20 @@ +@using Microsoft.AspNetCore.Components.QuickGrid + +
+ + + + +
+ +@code { + private IQueryable items = Enumerable.Range(1, 1000) + .Select(i => new Person { Id = i, Name = $"Person {i}" }) + .AsQueryable(); + + internal class Person + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor new file mode 100644 index 000000000000..08afaf017d1f --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor @@ -0,0 +1,38 @@ +@inject NavigationManager Nav + +@if (UseItemsProvider) +{ +
+ + +
Async item @context
+
+ +
Loading...
+
+
+
+} +else +{ +
+ +
Item @context
+
+
+} + +@code { + private List fixedItems = Enumerable.Range(0, 1000).ToList(); + + private bool UseItemsProvider => Nav.Uri.Contains("mode=items-provider", StringComparison.Ordinal); + + private async ValueTask> LoadItemsAsync(ItemsProviderRequest request) + { + // Delay so placeholders actually render and the placeholder + // data-blazor-virtualize-* path is exercised. + await Task.Delay(50, request.CancellationToken); + var items = Enumerable.Range(request.StartIndex, request.Count).ToArray(); + return new ItemsProviderResult(items, totalItemCount: 1000); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css index 4aa9426ab141..97f82b0d054e 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css @@ -39,3 +39,20 @@ .blazor-error-boundary::after { content: "An error has occurred." } + +#csp-container { + background-color: #eee; + height: 500px; + overflow-y: auto; +} + +.csp-item { + height: 50px; +} + +#csp-quickgrid { + background-color: #eee; + height: 500px; + overflow-y: auto; +} + diff --git a/src/Components/test/testassets/Components.TestServer/ServerStartup.cs b/src/Components/test/testassets/Components.TestServer/ServerStartup.cs index ca1db15a993c..e95f44ce2988 100644 --- a/src/Components/test/testassets/Components.TestServer/ServerStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/ServerStartup.cs @@ -72,6 +72,12 @@ public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment env, resourceRequestLog.AddRequest(context.Request); } + // Opt-in strict style-src CSP for tests asserting CSP compliance. + if (context.Request.Query.ContainsKey("strict-style-csp")) + { + context.Response.Headers["Content-Security-Policy"] = "style-src 'self'"; + } + return next(context); });