From c8d55df334b0cc2759d69aa5d20625520a4c1b7a Mon Sep 17 00:00:00 2001 From: ilonatommy Date: Thu, 14 May 2026 15:24:54 +0200 Subject: [PATCH 01/24] MutationObserver fix. --- .../BlazorUnitedApp/Pages/VirtualizeCsp.razor | 42 ++++++++++ .../Pages/VirtualizeCsp.razor.css | 21 +++++ .../Samples/BlazorUnitedApp/Program.cs | 9 ++ src/Components/Web.JS/src/Virtualize.ts | 43 +++++++++- .../Web/src/Virtualization/Virtualize.cs | 6 +- .../Web/test/Virtualization/VirtualizeTest.cs | 83 +++++++++++++++++++ 6 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor create mode 100644 src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor b/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor new file mode 100644 index 000000000000..aa60f3d30f91 --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor @@ -0,0 +1,42 @@ +@page "/virtualize-csp" +@rendermode InteractiveServer + +

Virtualize CSP Compliance Test

+

This page uses a strict CSP policy (style-src 'self'). Check the browser console for CSP violations.

+ +
+ + +
+ Item @item.Index (@item.Lines lines) +

@item.Content

+
+
+ +
Loading...
+
+
+
+ +@code { + private static readonly Random _random = new(); + private static readonly string _lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. "; + + private async ValueTask> LoadItems(ItemsProviderRequest request) + { + await Task.Delay(300, request.CancellationToken); + + var items = Enumerable.Range(request.StartIndex, request.Count) + .Select(i => + { + var lines = _random.Next(1, 10); + var content = string.Concat(Enumerable.Repeat(_lorem, lines)); + return new ItemData(i, lines, content); + }) + .ToList(); + + return new ItemsProviderResult(items, 10_000); + } + + private record ItemData(int Index, int Lines, string Content); +} diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css b/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css new file mode 100644 index 000000000000..64cc05e9391f --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css @@ -0,0 +1,21 @@ +.virtualize-scroll-container { + height: 400px; + overflow-y: auto; + border: 1px solid #ccc; +} + +.item-row { + padding: 4px 12px; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; +} + +.placeholder-row { + height: 50px; + padding: 4px 12px; + border-bottom: 1px solid #eee; + color: #999; + display: flex; + align-items: center; +} diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 6492f3fb3e50..65a461d4fa78 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -25,6 +25,15 @@ app.UseHttpsRedirection(); +app.Use(async (context, next) => +{ + if (context.Request.Path.StartsWithSegments("/virtualize-csp")) + { + context.Response.Headers["Content-Security-Policy"] = "style-src 'self';"; + } + await next(); +}); + app.UseStaticFiles(); app.UseAntiforgery(); diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 703628420ce3..06e0623ae5c1 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -79,6 +79,47 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement.style.overflowAnchor = 'none'; } + // Apply spacer styles from data attributes via CSSOM (CSP-compliant). + function applySpacerStylesFromAttributes(el: HTMLElement): void { + const style = el.getAttribute('data-blazor-spacer-style'); + if (style) { + el.style.cssText = style; + } + } + + // Apply initial styles from data attributes already present in the DOM. + applySpacerStylesFromAttributes(spacerBefore); + applySpacerStylesFromAttributes(spacerAfter); + for (const el of Array.from(spacerBefore.parentElement!.querySelectorAll('[data-blazor-spacer-style]'))) { + applySpacerStylesFromAttributes(el); + } + + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + applySpacerStylesFromAttributes(mutation.target as HTMLElement); + } else if (mutation.type === 'childList') { + for (const node of Array.from(mutation.addedNodes)) { + if (node instanceof HTMLElement) { + if (node.hasAttribute('data-blazor-spacer-style')) { + applySpacerStylesFromAttributes(node); + } + for (const child of Array.from(node.querySelectorAll('[data-blazor-spacer-style]'))) { + applySpacerStylesFromAttributes(child); + } + } + } + } + } + }); + mutationObserver.observe(spacerBefore.parentElement!, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['data-blazor-spacer-style'], + }); + + const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, rootMargin: `${rootMargin}px`, @@ -185,7 +226,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'; @@ -438,6 +478,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac restoreAnchor: restoreAnchorForShift, 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 37d9cbd93ba0..84ddedf176c1 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -320,7 +320,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } builder.OpenElement(0, SpacerElement); - builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore)); + builder.AddAttribute(1, "data-blazor-spacer-style", GetSpacerStyle(_itemsBefore)); builder.AddAttribute(2, "aria-hidden", "true"); builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference); builder.CloseElement(); @@ -389,7 +389,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(7, SpacerElement); builder.AddAttribute(8, "aria-hidden", "true"); - builder.AddAttribute(9, "style", GetSpacerStyle(itemsAfter, _unusedItemCapacity)); + builder.AddAttribute(9, "data-blazor-spacer-style", GetSpacerStyle(itemsAfter, _unusedItemCapacity)); builder.AddElementReferenceCapture(10, elementReference => _spacerAfter = elementReference); builder.CloseElement(); @@ -698,7 +698,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-spacer-style", $"height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"); builder.CloseElement(); }; diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 17d0b176378d..501375aff34f 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -963,4 +963,87 @@ await testRenderer.Dispatcher.InvokeAsync(() => $"In-memory append on value-type TItem must not trigger prepend detection (shift by countDelta). " + $"Before: {itemsBeforeAfterInit}, After: {renderedVirtualize._itemsBefore}, Shift: {shift}"); } + + [Fact] + public async Task Virtualize_SpacersRenderDataAttributesForCspCompliance() + { + 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-spacer-style instead of inline style attributes + // for CSP compliance. A MutationObserver on the JS side applies them via CSSOM. + var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); + + var dataStyleAttributes = referenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Attribute + && f.AttributeName == "data-blazor-spacer-style") + .ToList(); + + // Both spacerBefore and spacerAfter should have the data attribute + Assert.Equal(2, dataStyleAttributes.Count); + + // spacerBefore style: _itemsBefore is 0 initially, so height = 0px + var beforeStyle = (string)dataStyleAttributes[0].AttributeValue; + Assert.Contains("height: 0px", beforeStyle); + Assert.Contains("flex-shrink: 0", beforeStyle); + + // spacerAfter style: should contain a height value + var afterStyle = (string)dataStyleAttributes[1].AttributeValue; + Assert.Contains("height:", afterStyle); + Assert.Contains("flex-shrink: 0", afterStyle); + } + + [Fact] + public async Task Virtualize_SpacersDoNotRenderInlineStyleAttributes() + { + 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 must NOT have inline style attributes (CSP compliance). + // Styles are applied via MutationObserver + CSSOM on the JS side. + var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); + + var styleAttributes = referenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Attribute + && f.AttributeName == "style") + .ToList(); + + // No spacer should have a "style" attribute — only "data-blazor-spacer-style" + // The only style attribute in the output may come from the placeholder items, not spacers. + var spacerStyleAttributes = styleAttributes + .Where(f => ((string)f.AttributeValue).Contains("flex-shrink: 0")) + .ToList(); + + Assert.Empty(spacerStyleAttributes); + } } From d2b52dffeccb49b780fd4cc3bdef1941569114d0 Mon Sep 17 00:00:00 2001 From: ilonatommy Date: Thu, 14 May 2026 17:05:12 +0200 Subject: [PATCH 02/24] Move sample code to e2e test + apply feedback. --- .../BlazorUnitedApp/Pages/VirtualizeCsp.razor | 42 ----------- .../Samples/BlazorUnitedApp/Program.cs | 9 --- src/Components/Web.JS/src/Virtualize.ts | 70 +++++++++---------- .../Web/src/Virtualization/Virtualize.cs | 7 +- .../Web/test/Virtualization/VirtualizeTest.cs | 21 +++--- .../test/E2ETest/Tests/VirtualizationTest.cs | 38 ++++++++++ .../test/testassets/BasicTestApp/Index.razor | 1 + .../BasicTestApp/VirtualizationCsp.razor | 15 ++++ 8 files changed, 99 insertions(+), 104 deletions(-) delete mode 100644 src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor create mode 100644 src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor b/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor deleted file mode 100644 index aa60f3d30f91..000000000000 --- a/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor +++ /dev/null @@ -1,42 +0,0 @@ -@page "/virtualize-csp" -@rendermode InteractiveServer - -

Virtualize CSP Compliance Test

-

This page uses a strict CSP policy (style-src 'self'). Check the browser console for CSP violations.

- -
- - -
- Item @item.Index (@item.Lines lines) -

@item.Content

-
-
- -
Loading...
-
-
-
- -@code { - private static readonly Random _random = new(); - private static readonly string _lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. "; - - private async ValueTask> LoadItems(ItemsProviderRequest request) - { - await Task.Delay(300, request.CancellationToken); - - var items = Enumerable.Range(request.StartIndex, request.Count) - .Select(i => - { - var lines = _random.Next(1, 10); - var content = string.Concat(Enumerable.Repeat(_lorem, lines)); - return new ItemData(i, lines, content); - }) - .ToList(); - - return new ItemsProviderResult(items, 10_000); - } - - private record ItemData(int Index, int Lines, string Content); -} diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 65a461d4fa78..6492f3fb3e50 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -25,15 +25,6 @@ app.UseHttpsRedirection(); -app.Use(async (context, next) => -{ - if (context.Request.Path.StartsWithSegments("/virtualize-csp")) - { - context.Response.Headers["Content-Security-Policy"] = "style-src 'self';"; - } - await next(); -}); - app.UseStaticFiles(); app.UseAntiforgery(); diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 06e0623ae5c1..b5a308907fa5 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -70,6 +70,28 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac spacerAfter.style.display = 'table-row'; } + // Applies the style from a data attribute value using individual CSSOM setProperty calls + // (CSP-compliant). Unlike cssText assignment, setProperty() does not wipe other + // properties such as overflowAnchor or display that were set above. + function applyStyleViaCssom(el: HTMLElement, styleValue: string): void { + if (!styleValue) { + return; + } + for (const declaration of styleValue.split(';')) { + const colon = declaration.indexOf(':'); + if (colon < 0) continue; + const prop = declaration.substring(0, colon).trim(); + const value = declaration.substring(colon + 1).trim(); + if (prop && value) { + el.style.setProperty(prop, value); + } + } + } + + // Apply initial spacer styles via CSSOM so they work even under strict CSP. + applyStyleViaCssom(spacerBefore, spacerBefore.getAttribute('data-blazor-style') || ''); + applyStyleViaCssom(spacerAfter, spacerAfter.getAttribute('data-blazor-style') || ''); + if (useNativeAnchoring) { // Prevent spacers from being used as scroll anchors — only rendered items should anchor. spacerBefore.style.overflowAnchor = 'none'; @@ -79,46 +101,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement.style.overflowAnchor = 'none'; } - // Apply spacer styles from data attributes via CSSOM (CSP-compliant). - function applySpacerStylesFromAttributes(el: HTMLElement): void { - const style = el.getAttribute('data-blazor-spacer-style'); - if (style) { - el.style.cssText = style; - } - } - - // Apply initial styles from data attributes already present in the DOM. - applySpacerStylesFromAttributes(spacerBefore); - applySpacerStylesFromAttributes(spacerAfter); - for (const el of Array.from(spacerBefore.parentElement!.querySelectorAll('[data-blazor-spacer-style]'))) { - applySpacerStylesFromAttributes(el); - } - const mutationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { - if (mutation.type === 'attributes') { - applySpacerStylesFromAttributes(mutation.target as HTMLElement); - } else if (mutation.type === 'childList') { - for (const node of Array.from(mutation.addedNodes)) { - if (node instanceof HTMLElement) { - if (node.hasAttribute('data-blazor-spacer-style')) { - applySpacerStylesFromAttributes(node); - } - for (const child of Array.from(node.querySelectorAll('[data-blazor-spacer-style]'))) { - applySpacerStylesFromAttributes(child); - } - } - } - } + const el = mutation.target as HTMLElement; + applyStyleViaCssom(el, el.getAttribute('data-blazor-style') || ''); } }); - mutationObserver.observe(spacerBefore.parentElement!, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ['data-blazor-spacer-style'], - }); - + mutationObserver.observe(spacerBefore, { attributes: true, attributeFilter: ['data-blazor-style'] }); + mutationObserver.observe(spacerAfter, { attributes: true, attributeFilter: ['data-blazor-style'] }); const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, @@ -236,6 +226,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac spacerAfter.style.overflowAnchor = 'none'; } + // Apply CSSOM styles to any placeholder elements between spacers that use data-blazor-style. + for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { + const htmlEl = el as HTMLElement; + if (htmlEl.hasAttribute('data-blazor-style')) { + applyStyleViaCssom(htmlEl, htmlEl.getAttribute('data-blazor-style') || ''); + } + } + // Ensure spacers are always observed (idempotent). resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 84ddedf176c1..e09a22c03894 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -320,7 +320,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } builder.OpenElement(0, SpacerElement); - builder.AddAttribute(1, "data-blazor-spacer-style", GetSpacerStyle(_itemsBefore)); + builder.AddAttribute(1, "data-blazor-style", GetSpacerStyle(_itemsBefore)); builder.AddAttribute(2, "aria-hidden", "true"); builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference); builder.CloseElement(); @@ -389,9 +389,8 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(7, SpacerElement); builder.AddAttribute(8, "aria-hidden", "true"); - builder.AddAttribute(9, "data-blazor-spacer-style", GetSpacerStyle(itemsAfter, _unusedItemCapacity)); + builder.AddAttribute(9, "data-blazor-style", GetSpacerStyle(itemsAfter, _unusedItemCapacity)); builder.AddElementReferenceCapture(10, elementReference => _spacerAfter = elementReference); - builder.CloseElement(); } @@ -698,7 +697,7 @@ private ValueTask> DefaultItemsProvider(ItemsProvider private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builder) => { builder.OpenElement(0, "div"); - builder.AddAttribute(1, "data-blazor-spacer-style", $"height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"); + builder.AddAttribute(1, "data-blazor-style", $"height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"); builder.CloseElement(); }; diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 501375aff34f..9c52a252068b 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -985,13 +985,13 @@ public async Task Virtualize_SpacersRenderDataAttributesForCspCompliance() await testRenderer.RenderRootComponentAsync(componentId); Assert.NotNull(renderedVirtualize); - // Spacer elements use data-blazor-spacer-style instead of inline style attributes - // for CSP compliance. A MutationObserver on the JS side applies them via CSSOM. + // Spacer elements use data-blazor-style alongside the inline style attribute. + // A MutationObserver on the JS side reads data-blazor-style and applies via CSSOM. var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); var dataStyleAttributes = referenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Attribute - && f.AttributeName == "data-blazor-spacer-style") + && f.AttributeName == "data-blazor-style") .ToList(); // Both spacerBefore and spacerAfter should have the data attribute @@ -1029,19 +1029,14 @@ public async Task Virtualize_SpacersDoNotRenderInlineStyleAttributes() await testRenderer.RenderRootComponentAsync(componentId); Assert.NotNull(renderedVirtualize); - // Spacer elements must NOT have inline style attributes (CSP compliance). - // Styles are applied via MutationObserver + CSSOM on the JS side. + // Spacers must NOT have inline style attributes — only data-blazor-style. + // JS applies styles via CSSOM which is not blocked by CSP. var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); - var styleAttributes = referenceFrames + var spacerStyleAttributes = referenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Attribute - && f.AttributeName == "style") - .ToList(); - - // No spacer should have a "style" attribute — only "data-blazor-spacer-style" - // The only style attribute in the output may come from the placeholder items, not spacers. - var spacerStyleAttributes = styleAttributes - .Where(f => ((string)f.AttributeValue).Contains("flex-shrink: 0")) + && f.AttributeName == "style" + && ((string)f.AttributeValue).Contains("flex-shrink: 0")) .ToList(); Assert.Empty(spacerStyleAttributes); diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index deac6109d4cd..1db509d5f4ca 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -3941,4 +3941,42 @@ private Dictionary ExecuteViewportScrollJumpDetectionScript( return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); } + + [Fact] + public void SpacersUseCssomInsteadOfInlineStyleAttribute() + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("csp-container")); + + // Wait until items have been rendered. + Browser.True(() => container.FindElements(By.CssSelector(".csp-item")).Count > 0); + + // Get spacer elements (first and last div children of the container). + var spacers = container.FindElements(By.CssSelector(":scope > div")); + var topSpacer = spacers[0]; + + // The spacer should NOT have a "style" HTML attribute rendered by the server. + // Instead, styles are applied via CSSOM from the data-blazor-style attribute. + var dataBlazorStyle = topSpacer.GetDomAttribute("data-blazor-style"); + Assert.NotNull(dataBlazorStyle); + Assert.Contains("height:", dataBlazorStyle); + Assert.Contains("flex-shrink: 0", dataBlazorStyle); + + // The style attribute should be present (set by CSSOM setProperty), + // proving JS applied the styles programmatically. + var computedStyle = topSpacer.GetDomAttribute("style"); + Assert.Contains("height:", computedStyle); + Assert.Contains("flex-shrink: 0", computedStyle); + + // Scroll down and verify spacer style updates via CSSOM. + Browser.ExecuteJavaScript( + "document.getElementById('csp-container').scrollTop = 5000;"); + + // After scrolling, top spacer's data-blazor-style should have a non-zero height. + Browser.True(() => + { + var style = topSpacer.GetDomAttribute("data-blazor-style"); + return style != null && !style.Contains("height: 0px"); + }); + } } diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index cd3597b2a9a4..ac250fd806c4 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -143,6 +143,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor new file mode 100644 index 000000000000..8017bd75dff9 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationCsp.razor @@ -0,0 +1,15 @@ +@* + Test component for CSP compliance of Virtualize. + Verifies that spacer elements receive their styles via CSSOM (data-blazor-style) + rather than inline style attributes. +*@ + +
+ +
Item @context
+
+
+ +@code { + private List fixedItems = Enumerable.Range(0, 1000).ToList(); +} From f35e724321bc754c5fa87e7adbb27d9d4a4f2940 Mon Sep 17 00:00:00 2001 From: ilonatommy Date: Thu, 14 May 2026 17:31:10 +0200 Subject: [PATCH 03/24] cleanup --- .../Pages/VirtualizeCsp.razor.css | 21 ------------------- .../Web/test/Virtualization/VirtualizeTest.cs | 2 +- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css b/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css deleted file mode 100644 index 64cc05e9391f..000000000000 --- a/src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css +++ /dev/null @@ -1,21 +0,0 @@ -.virtualize-scroll-container { - height: 400px; - overflow-y: auto; - border: 1px solid #ccc; -} - -.item-row { - padding: 4px 12px; - border-bottom: 1px solid #eee; - display: flex; - align-items: center; -} - -.placeholder-row { - height: 50px; - padding: 4px 12px; - border-bottom: 1px solid #eee; - color: #999; - display: flex; - align-items: center; -} diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 9c52a252068b..27d323fc265f 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -985,7 +985,7 @@ public async Task Virtualize_SpacersRenderDataAttributesForCspCompliance() await testRenderer.RenderRootComponentAsync(componentId); Assert.NotNull(renderedVirtualize); - // Spacer elements use data-blazor-style alongside the inline style attribute. + // Spacer elements use data-blazor-style instead of inline style attributes. // A MutationObserver on the JS side reads data-blazor-style and applies via CSSOM. var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); From 0af68e22f0283720e1b5612b6e2d7e692fbebd1b Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 27 May 2026 17:26:08 +0200 Subject: [PATCH 04/24] Apply placeholders style in the same atomic task as spacers styles. --- src/Components/Web.JS/src/Virtualize.ts | 43 ++++++++++++++++--------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index b5a308907fa5..03fd17609da1 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -88,9 +88,11 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - // Apply initial spacer styles via CSSOM so they work even under strict CSP. - applyStyleViaCssom(spacerBefore, spacerBefore.getAttribute('data-blazor-style') || ''); - applyStyleViaCssom(spacerAfter, spacerAfter.getAttribute('data-blazor-style') || ''); + // Apply initial styles via CSSOM so they work even under strict CSP. + const styleObserverRoot = spacerBefore.parentElement ?? scrollElement; + styleObserverRoot.querySelectorAll('[data-blazor-style]').forEach(el => { + applyStyleViaCssom(el as HTMLElement, el.getAttribute('data-blazor-style') || ''); + }); if (useNativeAnchoring) { // Prevent spacers from being used as scroll anchors — only rendered items should anchor. @@ -103,12 +105,31 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const mutationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { - const el = mutation.target as HTMLElement; - applyStyleViaCssom(el, el.getAttribute('data-blazor-style') || ''); + if (mutation.type === 'attributes') { + const el = mutation.target as HTMLElement; + applyStyleViaCssom(el, el.getAttribute('data-blazor-style') || ''); + } else { + mutation.addedNodes.forEach(node => { + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + const el = node as Element; + if (el.hasAttribute('data-blazor-style')) { + applyStyleViaCssom(el as HTMLElement, el.getAttribute('data-blazor-style') || ''); + } + el.querySelectorAll('[data-blazor-style]').forEach(descendant => { + applyStyleViaCssom(descendant as HTMLElement, descendant.getAttribute('data-blazor-style') || ''); + }); + }); + } } }); - mutationObserver.observe(spacerBefore, { attributes: true, attributeFilter: ['data-blazor-style'] }); - mutationObserver.observe(spacerAfter, { attributes: true, attributeFilter: ['data-blazor-style'] }); + mutationObserver.observe(styleObserverRoot, { + attributes: true, + attributeFilter: ['data-blazor-style'], + childList: true, + subtree: true, + }); const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, @@ -226,14 +247,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac spacerAfter.style.overflowAnchor = 'none'; } - // Apply CSSOM styles to any placeholder elements between spacers that use data-blazor-style. - for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { - const htmlEl = el as HTMLElement; - if (htmlEl.hasAttribute('data-blazor-style')) { - applyStyleViaCssom(htmlEl, htmlEl.getAttribute('data-blazor-style') || ''); - } - } - // Ensure spacers are always observed (idempotent). resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); From a54ac13981adcd353e01d4460c0a8759843220b3 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 27 May 2026 18:02:48 +0200 Subject: [PATCH 05/24] Use CSS custom properties + static stylesheet for Virtualize styles Per @javiercn's review feedback, replace direct CSS property emission in data-blazor-style with --blazor-virtualize-* custom property assignments, consumed by a constructable stylesheet installed once via JS. spacerAfter now always emits --blazor-virtualize-transform (either 'none' or 'translateY(...)') so that a previously applied translateY cannot leak into a later render where _unusedItemCapacity transitions back to 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Components/Web.JS/src/Virtualize.ts | 22 ++++++++++++++--- .../Web/src/Virtualization/Virtualize.cs | 11 +++++---- .../Web/test/Virtualization/VirtualizeTest.cs | 13 +++++----- .../test/E2ETest/Tests/VirtualizationTest.cs | 24 +++++++++---------- 4 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 03fd17609da1..d01a2e3626f9 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -15,6 +15,21 @@ export const Virtualize = { const dispatcherObserversByDotNetIdPropname = Symbol(); const THROTTLE_MS = 50; +// Static stylesheet consumed by data-blazor-style custom properties. +// The transform fallback handles elements (spacerBefore, placeholders) that never emit it. +let virtualizeStylesheetInstalled = false; +function ensureVirtualizeStylesheet(): void { + if (virtualizeStylesheetInstalled) { + return; + } + virtualizeStylesheetInstalled = true; + const sheet = new CSSStyleSheet(); + sheet.replaceSync( + '[data-blazor-style]{height:var(--blazor-virtualize-height);flex-shrink:var(--blazor-virtualize-flex-shrink);transform:var(--blazor-virtualize-transform,none);}' + ); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; +} + function findClosestScrollContainer(element: HTMLElement | null): HTMLElement | null { // If we recurse up as far as body or the document root, return null so that the // IntersectionObserver observes intersection with the top-level scroll viewport @@ -51,6 +66,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return; } + ensureVirtualizeStylesheet(); + const scrollContainer = findClosestScrollContainer(spacerBefore); const scrollElement = scrollContainer || document.documentElement; const isTable = isValidTableElement(spacerAfter.parentElement); @@ -70,9 +87,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac spacerAfter.style.display = 'table-row'; } - // Applies the style from a data attribute value using individual CSSOM setProperty calls - // (CSP-compliant). Unlike cssText assignment, setProperty() does not wipe other - // properties such as overflowAnchor or display that were set above. + // Applies declarations from data-blazor-style via CSSOM setProperty (CSP-compliant). + // Values are CSS custom properties consumed by the rule from ensureVirtualizeStylesheet. function applyStyleViaCssom(el: HTMLElement, styleValue: string): void { if (!styleValue) { return; diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index e09a22c03894..ee4c53c09163 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -397,13 +397,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) 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);"; + var transformValue = numItemsGapAbove == 0 + ? "none" + : $"translateY({(numItemsGapAbove * avgHeight).ToString(CultureInfo.InvariantCulture)}px)"; + return $"--blazor-virtualize-height: {(itemsInSpacer * avgHeight).ToString(CultureInfo.InvariantCulture)}px; --blazor-virtualize-flex-shrink: 0; --blazor-virtualize-transform: {transformValue};"; } private string GetSpacerStyle(int itemsInSpacer) - => $"height: {(itemsInSpacer * GetItemHeight()).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"; + => $"--blazor-virtualize-height: {(itemsInSpacer * GetItemHeight()).ToString(CultureInfo.InvariantCulture)}px; --blazor-virtualize-flex-shrink: 0;"; private float GetItemHeight() => _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize; @@ -697,7 +698,7 @@ private ValueTask> DefaultItemsProvider(ItemsProvider private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builder) => { builder.OpenElement(0, "div"); - builder.AddAttribute(1, "data-blazor-style", $"height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"); + builder.AddAttribute(1, "data-blazor-style", $"--blazor-virtualize-height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; --blazor-virtualize-flex-shrink: 0;"); builder.CloseElement(); }; diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 27d323fc265f..6ec79cb24752 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -999,13 +999,14 @@ public async Task Virtualize_SpacersRenderDataAttributesForCspCompliance() // spacerBefore style: _itemsBefore is 0 initially, so height = 0px var beforeStyle = (string)dataStyleAttributes[0].AttributeValue; - Assert.Contains("height: 0px", beforeStyle); - Assert.Contains("flex-shrink: 0", beforeStyle); + Assert.Contains("--blazor-virtualize-height: 0px", beforeStyle); + Assert.Contains("--blazor-virtualize-flex-shrink: 0", beforeStyle); - // spacerAfter style: should contain a height value + // spacerAfter style: height + flex-shrink + explicit transform on every render. var afterStyle = (string)dataStyleAttributes[1].AttributeValue; - Assert.Contains("height:", afterStyle); - Assert.Contains("flex-shrink: 0", afterStyle); + Assert.Contains("--blazor-virtualize-height:", afterStyle); + Assert.Contains("--blazor-virtualize-flex-shrink: 0", afterStyle); + Assert.Contains("--blazor-virtualize-transform:", afterStyle); } [Fact] @@ -1036,7 +1037,7 @@ public async Task Virtualize_SpacersDoNotRenderInlineStyleAttributes() var spacerStyleAttributes = referenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "style" - && ((string)f.AttributeValue).Contains("flex-shrink: 0")) + && ((string)f.AttributeValue).Contains("--blazor-virtualize-flex-shrink: 0")) .ToList(); Assert.Empty(spacerStyleAttributes); diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 1db509d5f4ca..2e22f66d2005 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -39,7 +39,7 @@ 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 expectedInitialSpacerStyle = "--blazor-virtualize-height: 0px; --blazor-virtualize-flex-shrink: 0; overflow-anchor: none;"; int initialItemCount = 0; @@ -202,7 +202,7 @@ public virtual void CancelsOutdatedRefreshes_Async() public void CanUseViewportAsContainer() { Browser.MountTestComponent(); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; + var expectedInitialSpacerStyle = "--blazor-virtualize-height: 0px; --blazor-virtualize-flex-shrink: 0; overflow-anchor: none;"; var topSpacer = Browser.Exists(By.Id("viewport-as-root")).FindElement(By.TagName("div")); Browser.ExecuteJavaScript("const element = document.getElementById('viewport-as-root'); element.scrollIntoView();"); @@ -226,7 +226,7 @@ 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 expectedInitialSpacerStyle = "--blazor-virtualize-height: 0px; --blazor-virtualize-flex-shrink: 0; overflow-anchor: none;"; // Wait until items have been rendered. Browser.True(() => GetItemCount() > 0); @@ -254,7 +254,7 @@ public async Task ToleratesIncorrectItemSize() public virtual void CanRenderHtmlTable() { Browser.MountTestComponent(); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "--blazor-virtualize-height: 0px; --blazor-virtualize-flex-shrink: 0;"; var topSpacer = Browser.Exists(By.CssSelector("#virtualized-table > tbody > :first-child")); var bottomSpacer = Browser.Exists(By.CssSelector("#virtualized-table > tbody > :last-child")); @@ -3955,18 +3955,16 @@ public void SpacersUseCssomInsteadOfInlineStyleAttribute() var spacers = container.FindElements(By.CssSelector(":scope > div")); var topSpacer = spacers[0]; - // The spacer should NOT have a "style" HTML attribute rendered by the server. - // Instead, styles are applied via CSSOM from the data-blazor-style attribute. + // data-blazor-style holds custom property assignments consumed by a static rule. var dataBlazorStyle = topSpacer.GetDomAttribute("data-blazor-style"); Assert.NotNull(dataBlazorStyle); - Assert.Contains("height:", dataBlazorStyle); - Assert.Contains("flex-shrink: 0", dataBlazorStyle); + Assert.Contains("--blazor-virtualize-height:", dataBlazorStyle); + Assert.Contains("--blazor-virtualize-flex-shrink: 0", dataBlazorStyle); - // The style attribute should be present (set by CSSOM setProperty), - // proving JS applied the styles programmatically. + // The inline style attribute reflects the CSSOM setProperty calls. var computedStyle = topSpacer.GetDomAttribute("style"); - Assert.Contains("height:", computedStyle); - Assert.Contains("flex-shrink: 0", computedStyle); + Assert.Contains("--blazor-virtualize-height:", computedStyle); + Assert.Contains("--blazor-virtualize-flex-shrink: 0", computedStyle); // Scroll down and verify spacer style updates via CSSOM. Browser.ExecuteJavaScript( @@ -3976,7 +3974,7 @@ public void SpacersUseCssomInsteadOfInlineStyleAttribute() Browser.True(() => { var style = topSpacer.GetDomAttribute("data-blazor-style"); - return style != null && !style.Contains("height: 0px"); + return style != null && !style.Contains("--blazor-virtualize-height: 0px"); }); } } From 58f35f751050e4cd228a2037e7756bde44c0afe8 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 27 May 2026 18:11:54 +0200 Subject: [PATCH 06/24] Add strict-style-csp E2E test for Virtualize Addresses review feedback asking for an actual CSP-enforced test. Adds a ?strict-style-csp query parameter to ServerStartup which emits 'Content-Security-Policy: style-src ''self''' so that any inline style or