@@ -25,6 +25,14 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
2525
2626 private int _visibleItemCapacity ;
2727
28+ // If the client reports a viewport so large that it could show more than MaxItemCount items,
29+ // we keep track of the "unused" capacity, which is the amount of blank space we want to leave
30+ // at the bottom of the viewport (as a number of items). If we didn't leave this blank space,
31+ // then the bottom spacer would always stay visible and the client would request more items in an
32+ // infinite (but asynchronous) loop, as it would believe there are more items to render and
33+ // enough space to render them into.
34+ private int _unusedItemCapacity ;
35+
2836 private int _itemCount ;
2937
3038 private int _loadedItemsStartIndex ;
@@ -118,6 +126,17 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
118126 [ Parameter ]
119127 public string SpacerElement { get ; set ; } = "div" ;
120128
129+ /// <summary>
130+ /// Gets or sets the maximum number of items that will be rendered, even if the client reports
131+ /// that its viewport is large enough to show more. The default value is 100.
132+ ///
133+ /// This should only be used as a safeguard against excessive memory usage or large data loads.
134+ /// Do not set this to a smaller number than you expect to fit on a realistic-sized window, because
135+ /// that will leave a blank gap below and the user may not be able to see the rest of the content.
136+ /// </summary>
137+ [ Parameter ]
138+ public int MaxItemCount { get ; set ; } = 100 ;
139+
121140 /// <summary>
122141 /// Instructs the component to re-request data from its <see cref="ItemsProvider"/>.
123142 /// This is useful if external data may have changed. There is no need to call this
@@ -264,18 +283,23 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
264283 var itemsAfter = Math . Max ( 0 , _itemCount - _visibleItemCapacity - _itemsBefore ) ;
265284
266285 builder . OpenElement ( 7 , SpacerElement ) ;
267- builder . AddAttribute ( 8 , "style" , GetSpacerStyle ( itemsAfter ) ) ;
286+ builder . AddAttribute ( 8 , "style" , GetSpacerStyle ( itemsAfter , _unusedItemCapacity ) ) ;
268287 builder . AddElementReferenceCapture ( 9 , elementReference => _spacerAfter = elementReference ) ;
269288
270289 builder . CloseElement ( ) ;
271290 }
272291
292+ private string GetSpacerStyle ( int itemsInSpacer , int numItemsGapAbove )
293+ => numItemsGapAbove == 0
294+ ? GetSpacerStyle ( itemsInSpacer )
295+ : $ "height: { ( itemsInSpacer * _itemSize ) . ToString ( CultureInfo . InvariantCulture ) } px; flex-shrink: 0; transform: translateY({ ( numItemsGapAbove * _itemSize ) . ToString ( CultureInfo . InvariantCulture ) } px);";
296+
273297 private string GetSpacerStyle ( int itemsInSpacer )
274298 => $ "height: { ( itemsInSpacer * _itemSize ) . ToString ( CultureInfo . InvariantCulture ) } px; flex-shrink: 0;";
275299
276300 void IVirtualizeJsCallbacks . OnBeforeSpacerVisible ( float spacerSize , float spacerSeparation , float containerSize )
277301 {
278- CalcualteItemDistribution ( spacerSize , spacerSeparation , containerSize , out var itemsBefore , out var visibleItemCapacity ) ;
302+ CalcualteItemDistribution ( spacerSize , spacerSeparation , containerSize , out var itemsBefore , out var visibleItemCapacity , out var unusedItemCapacity ) ;
279303
280304 // Since we know the before spacer is now visible, we absolutely have to slide the window up
281305 // by at least one element. If we're not doing that, the previous item size info we had must
@@ -286,12 +310,12 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer
286310 itemsBefore -- ;
287311 }
288312
289- UpdateItemDistribution ( itemsBefore , visibleItemCapacity ) ;
313+ UpdateItemDistribution ( itemsBefore , visibleItemCapacity , unusedItemCapacity ) ;
290314 }
291315
292316 void IVirtualizeJsCallbacks . OnAfterSpacerVisible ( float spacerSize , float spacerSeparation , float containerSize )
293317 {
294- CalcualteItemDistribution ( spacerSize , spacerSeparation , containerSize , out var itemsAfter , out var visibleItemCapacity ) ;
318+ CalcualteItemDistribution ( spacerSize , spacerSeparation , containerSize , out var itemsAfter , out var visibleItemCapacity , out var unusedItemCapacity ) ;
295319
296320 var itemsBefore = Math . Max ( 0 , _itemCount - itemsAfter - visibleItemCapacity ) ;
297321
@@ -304,15 +328,16 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
304328 itemsBefore ++ ;
305329 }
306330
307- UpdateItemDistribution ( itemsBefore , visibleItemCapacity ) ;
331+ UpdateItemDistribution ( itemsBefore , visibleItemCapacity , unusedItemCapacity ) ;
308332 }
309333
310334 private void CalcualteItemDistribution (
311335 float spacerSize ,
312336 float spacerSeparation ,
313337 float containerSize ,
314338 out int itemsInSpacer ,
315- out int visibleItemCapacity )
339+ out int visibleItemCapacity ,
340+ out int unusedItemCapacity )
316341 {
317342 if ( _lastRenderedItemCount > 0 )
318343 {
@@ -326,11 +351,22 @@ private void CalcualteItemDistribution(
326351 _itemSize = ItemSize ;
327352 }
328353
354+ // This AppContext data was added as a stopgap for .NET 8 and earlier, since it was added in a patch
355+ // where we couldn't add new public API. For backcompat we still support the AppContext setting, but
356+ // new applications should use the much more convenient MaxItemCount parameter.
357+ var maxItemCount = AppContext . GetData ( "Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount" ) switch
358+ {
359+ int val => Math . Min ( val , MaxItemCount ) ,
360+ _ => MaxItemCount
361+ } ;
362+
329363 itemsInSpacer = Math . Max ( 0 , ( int ) Math . Floor ( spacerSize / _itemSize ) - OverscanCount ) ;
330364 visibleItemCapacity = ( int ) Math . Ceiling ( containerSize / _itemSize ) + 2 * OverscanCount ;
365+ unusedItemCapacity = Math . Max ( 0 , visibleItemCapacity - maxItemCount ) ;
366+ visibleItemCapacity -= unusedItemCapacity ;
331367 }
332368
333- private void UpdateItemDistribution ( int itemsBefore , int visibleItemCapacity )
369+ private void UpdateItemDistribution ( int itemsBefore , int visibleItemCapacity , int unusedItemCapacity )
334370 {
335371 // If the itemcount just changed to a lower number, and we're already scrolled past the end of the new
336372 // reduced set of items, clamp the scroll position to the new maximum
@@ -340,10 +376,11 @@ private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
340376 }
341377
342378 // If anything about the offset changed, re-render
343- if ( itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity )
379+ if ( itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity || unusedItemCapacity != _unusedItemCapacity )
344380 {
345381 _itemsBefore = itemsBefore ;
346382 _visibleItemCapacity = visibleItemCapacity ;
383+ _unusedItemCapacity = unusedItemCapacity ;
347384 var refreshTask = RefreshDataCoreAsync ( renderOnSuccess : true ) ;
348385
349386 if ( ! refreshTask . IsCompleted )
0 commit comments