Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c8d55df
MutationObserver fix.
ilonatommy May 14, 2026
d2b52df
Move sample code to e2e test + apply feedback.
ilonatommy May 14, 2026
f35e724
cleanup
ilonatommy May 14, 2026
0af68e2
Apply placeholders style in the same atomic task as spacers styles.
ilonatommy May 27, 2026
a54ac13
Use CSS custom properties + static stylesheet for Virtualize styles
ilonatommy May 27, 2026
d7cd8e5
Merge remote-tracking branch 'origin/main' into virtuazliation-does-n…
ilonatommy May 27, 2026
58f35f7
Add strict-style-csp E2E test for Virtualize
ilonatommy May 27, 2026
ec4ec33
Fix tests: apply styles synchonously.
ilonatommy May 28, 2026
dd53eb0
Merge remote-tracking branch 'origin/main' into virtuazliation-does-n…
ilonatommy Jun 2, 2026
a263b72
Merge branch 'main' into virtuazliation-does-not-interfere-with-stric…
ilonatommy Jun 2, 2026
e2407e9
Fallback with `none` css can be in JS only.
ilonatommy Jun 2, 2026
3d11c5a
Revert blank line removal.
ilonatommy Jun 2, 2026
50d2980
Test cleanup.
ilonatommy Jun 2, 2026
7d74c8e
Feedback: itemprovider tests.
ilonatommy Jun 3, 2026
6530b3d
Rename to a better scoped string.
ilonatommy Jun 3, 2026
9e1e356
The number of items we're changing styles of is not enough to justify…
ilonatommy Jun 3, 2026
2d42322
Simplify style communication: each operation has a separate custom at…
ilonatommy Jun 3, 2026
6a030f8
Add scrubbing + test.
ilonatommy Jun 3, 2026
a83c6db
Fix test.
ilonatommy Jun 3, 2026
e237ef9
Use the renderer to serialize the style attribute.
ilonatommy Jun 3, 2026
22450ee
Cleanup.
ilonatommy Jun 3, 2026
cc38822
Remove duplicate comment.
ilonatommy Jun 4, 2026
97bd7f0
C# doesn't override the styles anymore, re-applying them in JS is no-…
ilonatommy Jun 4, 2026
e48859f
Add test for QuickGrid.
ilonatommy Jun 4, 2026
bde7dcf
Limit observed nodes to spacers.
ilonatommy Jun 4, 2026
b701652
Using custom properties is not justified anymore.
ilonatommy Jun 5, 2026
a54205e
Feedback: correct stale comment.
ilonatommy Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Comment thread
ilonatommy marked this conversation as resolved.
const intersectionObserver = new IntersectionObserver(intersectionCallback, {
root: scrollContainer,
rootMargin: `${rootMargin}px`,
Expand Down Expand Up @@ -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.
Comment thread
ilonatommy marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
24 changes: 10 additions & 14 deletions src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -938,7 +934,7 @@ private ValueTask<ItemsProviderResult<TItem>> 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();
};

Expand Down
45 changes: 45 additions & 0 deletions src/Components/Web/test/Virtualization/VirtualizeTest.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<int> 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<IJSRuntime>())
.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);
}
}
Original file line number Diff line number Diff line change
@@ -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<BasicTestAppServerSiteFixture<ServerStartup>>
{
public VirtualizationCspTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<ServerStartup> 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<VirtualizationCsp>();

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<VirtualizationCsp>();

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<VirtualizationCsp>();

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<BasicTestApp.QuickGridTest.QuickGridCsp>();

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();
Comment thread
ilonatommy marked this conversation as resolved.
Assert.Empty(styleErrors);
}
}
Loading
Loading