diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index ca5ef788..debcbc10 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -13,6 +13,7 @@ import '../utils/grid_size_calculator.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; import '../widgets/skeleton_media_card.dart'; +import '../widgets/sliver_cross_axis_layout_builder.dart'; /// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a /// Flutter widget Key. @@ -202,27 +203,22 @@ mixin FocusableDetailScreenMixin on State, GridFocu ); } - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity); return SliverPadding( padding: const EdgeInsets.all(8), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); - final columnCount = GridSizeCalculator.getColumnCount( - constraints.crossAxisExtent, - maxExtent, - crossAxisSpacing: gridSpacing, + sliver: SliverCrossAxisLayoutBuilder( + builder: (context, crossAxisExtent) { + final geometry = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: crossAxisExtent, + density: libraryDensity, + fullBleedImage: fullCardLayout, ); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: libraryDensity, - fullBleedImage: fullCardLayout, - ), + gridDelegate: geometry.delegate, itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - final inFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); + final inFirstRow = GridSizeCalculator.isFirstRow(index, geometry.columnCount); final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( @@ -298,27 +294,22 @@ mixin FocusableDetailScreenMixin on State, GridFocu ); } - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, libraryDensity); return SliverPadding( padding: const EdgeInsets.all(8), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); - final columnCount = GridSizeCalculator.getColumnCount( - constraints.crossAxisExtent, - maxExtent, - crossAxisSpacing: gridSpacing, + sliver: SliverCrossAxisLayoutBuilder( + builder: (context, crossAxisExtent) { + final geometry = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: crossAxisExtent, + density: libraryDensity, + fullBleedImage: fullCardLayout, ); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: libraryDensity, - fullBleedImage: fullCardLayout, - ), + gridDelegate: geometry.delegate, itemCount: totalItems, itemBuilder: (context, index) => buildTile( index, - inFirstRow: GridSizeCalculator.isFirstRow(index, columnCount), + inFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount), disableScale: false, ), ); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index a3d18947..d3845c37 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -19,6 +19,7 @@ import '../utils/provider_extensions.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/media_grid_delegate.dart'; +import '../widgets/sliver_cross_axis_layout_builder.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/overlay_sheet.dart'; @@ -561,32 +562,21 @@ class _HubDetailScreenState extends State return SliverPadding( padding: const EdgeInsets.all(8), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( - context, - libraryDensity, - 16, - ); - final gridSpacing = MediaGridDelegate.spacingFor( + sliver: SliverCrossAxisLayoutBuilder( + builder: (context, crossAxisExtent) { + final geometry = MediaGridGeometry.resolve( context: context, + crossAxisExtent: crossAxisExtent, + density: libraryDensity, + usePaddingAware: true, + horizontalPadding: 16, + useWideAspectRatio: useWideLayout, fullBleedImage: fullCardLayout, ); - final columnCount = GridSizeCalculator.getColumnCount( - constraints.crossAxisExtent, - useWideLayout ? maxExtent * 1.8 : maxExtent, - crossAxisSpacing: gridSpacing, - ); + final columnCount = geometry.columnCount; return SliverGrid( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: libraryDensity, - usePaddingAware: true, - horizontalPadding: 16, - useWideAspectRatio: useWideLayout, - fullBleedImage: fullCardLayout, - ), + gridDelegate: geometry.delegate, delegate: SliverChildBuilderDelegate((context, index) { final item = _filteredItems[index]; final focusNode = _focusNodeForIndex(index); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 875999a8..28d04b20 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -34,8 +34,10 @@ import '../library_alpha_scroll_metrics.dart'; import '../library_filter_sort_loader.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; +import '../../../widgets/listenable_selector.dart'; import '../../../widgets/loading_indicator_box.dart'; import '../../../widgets/media_grid_delegate.dart'; +import '../../../widgets/sliver_cross_axis_layout_builder.dart'; import '../../../widgets/media_card_list_layout.dart'; import '../../../widgets/bottom_sheet_page_scaffold.dart'; import '../../../widgets/overlay_sheet.dart'; @@ -1311,26 +1313,31 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( - valueListenable: _currentFirstVisibleIndex, - builder: (context, visibleIndex, _) => ValueListenableBuilder( + ? ListenableSelector( + listenable: _currentFirstVisibleIndex, + selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value), + builder: (context, currentLetter, _) => ValueListenableBuilder( valueListenable: _isScrollActive, builder: (context, scrolling, _) => AlphaScrollHandle( firstCharacters: _firstCharacters, onJump: _jumpToIndex, - currentLetter: _alphaLetterFor(visibleIndex), + currentLetter: currentLetter, descending: _isTitleSortDescending, isScrolling: scrolling, ), ), ) - : ValueListenableBuilder( - valueListenable: _currentFirstVisibleIndex, - builder: (context, visibleIndex, _) => AlphaJumpBar( + : ListenableSelector( + listenable: _currentFirstVisibleIndex, + selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value), + builder: (context, currentLetter, _) => AlphaJumpBar( firstCharacters: _firstCharacters, onJump: _jumpToIndex, - currentLetter: _alphaLetterFor(visibleIndex), + currentLetter: currentLetter, descending: _isTitleSortDescending, focusNode: _alphaJumpBarFocusNode, onNavigateLeft: _navigateToGridNearScroll, @@ -1702,72 +1709,54 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 8.0; return SliverPadding( padding: .fromLTRB(8, topPadding, rightPadding, 8), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); - // Compute column count from the width the grid would have without the alpha - // bar's reservation, so toggling the bar doesn't repack the grid into one - // fewer column and blow up poster size. - final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0); - final columnCount = GridSizeCalculator.getColumnCount( - baselineWidth, - effectiveMaxExtent, - crossAxisSpacing: gridSpacing, + sliver: SliverCrossAxisLayoutBuilder( + builder: (context, crossAxisExtent) { + final geometry = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: crossAxisExtent, + // Compute column count from the width the grid would have without + // the alpha bar's reservation, so toggling the bar doesn't repack + // the grid into one fewer column and blow up poster size. + crossAxisExtentForColumnCount: hasAlphaBarReservation ? crossAxisExtent + (rightPadding - 8.0) : null, + density: libraryDensity, + useWideAspectRatio: useWideRatio, + fullBleedImage: fullCardLayout, ); + final columnCount = geometry.columnCount; // Cache grid metrics for alpha jump bar scroll calculations - final itemWidth = GridSizeCalculator.getCellWidthForColumnCount( - constraints.crossAxisExtent, - columnCount, - crossAxisSpacing: gridSpacing, - ); - final itemHeight = - itemWidth / - MediaGridDelegate.aspectRatioFor(useWideAspectRatio: useWideRatio, fullBleedImage: fullCardLayout); _scrollMetrics = LibraryAlphaScrollMetrics( columnCount: columnCount, - rowHeight: itemHeight + gridSpacing, - itemWidth: itemWidth, - itemHeight: itemHeight, + rowHeight: geometry.itemHeight + geometry.spacing, + itemWidth: geometry.itemWidth, + itemHeight: geometry.itemHeight, ); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: libraryDensity, - useWideAspectRatio: useWideRatio, - fullBleedImage: fullCardLayout, - maxCrossAxisExtentOverride: hasAlphaBarReservation ? itemWidth : null, - ), + gridDelegate: geometry.delegate, itemCount: itemCount, itemBuilder: (context, index) => _buildMediaCardItem( index, diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index e4f475f3..03eb7b1f 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -15,6 +15,7 @@ import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/settings_builder.dart'; import '../../../widgets/skeleton_media_card.dart'; +import '../../../widgets/sliver_cross_axis_layout_builder.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; import 'base_library_tab.dart'; @@ -147,25 +148,20 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState _buildMediaCardItem( index, - isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount), fullBleedImage: fullCardLayout, ), ); diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 35d55be8..c9bf4527 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -15,6 +15,7 @@ import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/settings_builder.dart'; import '../../../widgets/skeleton_media_card.dart'; +import '../../../widgets/sliver_cross_axis_layout_builder.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; import 'base_library_tab.dart'; @@ -149,25 +150,20 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState _buildPlaylistCard( index, - isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount), fullBleedImage: fullCardLayout, ), ); diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index b4f07aa5..49762eae 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -54,9 +54,11 @@ class GridSizeCalculator { /// is added to the denominator only, not the numerator: /// `(crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil()` /// - /// [crossAxisExtent] should come from layout constraints (e.g. `SliverLayoutBuilder` - /// or `LayoutBuilder`), not from `MediaQuery`, to account for sidebars or other - /// elements that reduce the grid's actual width. + /// [crossAxisExtent] should come from layout constraints (e.g. + /// `SliverCrossAxisLayoutBuilder` or `LayoutBuilder`), not from `MediaQuery`, + /// to account for sidebars or other elements that reduce the grid's actual + /// width. Never use a plain `SliverLayoutBuilder` for this: its constraints + /// include the scroll offset, so it rebuilds the whole grid every scroll tick. static int getColumnCount( double crossAxisExtent, double maxCrossAxisExtent, { diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index 577fdb0b..c20be11b 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -25,20 +25,15 @@ class MediaGridDelegate { final aspectRatio = aspectRatioFor(useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage); final spacing = spacingFor(context: context, fullBleedImage: fullBleedImage); - double maxCrossAxisExtent; - if (maxCrossAxisExtentOverride != null) { - maxCrossAxisExtent = maxCrossAxisExtentOverride; - } else { - maxCrossAxisExtent = usePaddingAware - ? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding) - : GridSizeCalculator.getMaxCrossAxisExtent(context, density); - - // For wide aspect ratio (16:9), increase max extent so items are larger - // and there are fewer per row (roughly 1.8x wider to maintain similar visual area) - if (useWideAspectRatio) { - maxCrossAxisExtent *= 1.8; - } - } + final maxCrossAxisExtent = + maxCrossAxisExtentOverride ?? + _maxCrossAxisExtentFor( + context: context, + density: density, + usePaddingAware: usePaddingAware, + horizontalPadding: horizontalPadding, + useWideAspectRatio: useWideAspectRatio, + ); return SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, @@ -48,6 +43,27 @@ class MediaGridDelegate { ); } + /// Resolves the max cross-axis extent the way [createDelegate] does, + /// including the 1.8x widening for 16:9 episode thumbnails. + static double _maxCrossAxisExtentFor({ + required BuildContext context, + required int density, + required bool usePaddingAware, + required double horizontalPadding, + required bool useWideAspectRatio, + }) { + var maxCrossAxisExtent = usePaddingAware + ? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding) + : GridSizeCalculator.getMaxCrossAxisExtent(context, density); + + // For wide aspect ratio (16:9), increase max extent so items are larger + // and there are fewer per row (roughly 1.8x wider to maintain similar visual area) + if (useWideAspectRatio) { + maxCrossAxisExtent *= 1.8; + } + return maxCrossAxisExtent; + } + static double spacingFor({required BuildContext context, bool fullBleedImage = false}) { if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing; return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context)); @@ -63,3 +79,85 @@ class MediaGridDelegate { return useWideAspectRatio ? GridLayoutConstants.episodeGridCellAspectRatio : GridLayoutConstants.posterAspectRatio; } } + +/// The grid layout a media grid will render for a given cross-axis extent: +/// column count, cell size, spacing, and the matching delegate. +/// +/// Use with `SliverCrossAxisLayoutBuilder` so this is resolved once per +/// width/settings change — never per scroll tick. [columnCount] follows the +/// same formula [SliverGridDelegateWithMaxCrossAxisExtent] uses at layout +/// time (see [GridSizeCalculator.getColumnCount], issue #1288), so d-pad row +/// math and the rendered grid always agree. +class MediaGridGeometry { + final int columnCount; + final double itemWidth; + final double itemHeight; + final double spacing; + final SliverGridDelegateWithMaxCrossAxisExtent delegate; + + const MediaGridGeometry._({ + required this.columnCount, + required this.itemWidth, + required this.itemHeight, + required this.spacing, + required this.delegate, + }); + + /// Resolves the geometry for a grid laid out in [crossAxisExtent] (the + /// sliver's width AFTER any wrapping [SliverPadding]). + /// + /// [crossAxisExtentForColumnCount], when non-null, computes the column + /// count from that width instead, and pins the delegate's cell width to the + /// resulting [itemWidth] — used by the library browse grid so the alpha + /// jump bar's reservation doesn't repack the grid into fewer columns. + static MediaGridGeometry resolve({ + required BuildContext context, + required double crossAxisExtent, + required int density, + double? crossAxisExtentForColumnCount, + bool usePaddingAware = false, + double horizontalPadding = 16, + bool useWideAspectRatio = false, + bool fullBleedImage = false, + }) { + final spacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullBleedImage); + final aspectRatio = MediaGridDelegate.aspectRatioFor( + useWideAspectRatio: useWideAspectRatio, + fullBleedImage: fullBleedImage, + ); + final maxCrossAxisExtent = MediaGridDelegate._maxCrossAxisExtentFor( + context: context, + density: density, + usePaddingAware: usePaddingAware, + horizontalPadding: horizontalPadding, + useWideAspectRatio: useWideAspectRatio, + ); + + final columnCount = GridSizeCalculator.getColumnCount( + crossAxisExtentForColumnCount ?? crossAxisExtent, + maxCrossAxisExtent, + crossAxisSpacing: spacing, + ); + final itemWidth = GridSizeCalculator.getCellWidthForColumnCount( + crossAxisExtent, + columnCount, + crossAxisSpacing: spacing, + ); + + return MediaGridGeometry._( + columnCount: columnCount, + itemWidth: itemWidth, + itemHeight: itemWidth / aspectRatio, + spacing: spacing, + delegate: SliverGridDelegateWithMaxCrossAxisExtent( + // When the column count is pinned to a different basis width, the + // delegate must pack exactly [columnCount] columns into the real + // extent, so cap cells at the derived width instead. + maxCrossAxisExtent: crossAxisExtentForColumnCount != null ? itemWidth : maxCrossAxisExtent, + childAspectRatio: aspectRatio, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, + ), + ); + } +} diff --git a/lib/widgets/sliver_cross_axis_layout_builder.dart b/lib/widgets/sliver_cross_axis_layout_builder.dart new file mode 100644 index 00000000..01bcb92c --- /dev/null +++ b/lib/widgets/sliver_cross_axis_layout_builder.dart @@ -0,0 +1,79 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A [SliverLayoutBuilder] variant whose builder only depends on the sliver's +/// cross-axis extent. +/// +/// [SliverConstraints] include the scroll offset, so a plain +/// [SliverLayoutBuilder] re-invokes its builder on EVERY scroll tick — each +/// tick rebuilds the returned sliver widget, which marks every realized grid +/// child dirty for build AND layout (a 100-200ms frame on low-end TV boxes +/// with ~30 cards realized). Grid geometry (column count, cell size) only +/// depends on the cross-axis extent, so this widget keys the rebuild decision +/// on [SliverConstraints.crossAxisExtent] alone: the builder re-runs when the +/// width changes (resize, sidebar layout change) or when this widget itself +/// is rebuilt (normal data/settings updates), never from scrolling. +/// +/// The framework's [AbstractLayoutBuilder] contract handles the caching: the +/// builder is skipped when [RenderAbstractLayoutBuilderMixin.layoutInfo] +/// compares equal to its value from the previous layout pass. +class SliverCrossAxisLayoutBuilder extends AbstractLayoutBuilder { + const SliverCrossAxisLayoutBuilder({super.key, required this.builder}); + + /// Called at layout time with the sliver's cross-axis extent. Must return + /// a sliver. + @override + final Widget Function(BuildContext context, double crossAxisExtent) builder; + + @override + RenderAbstractLayoutBuilderMixin createRenderObject(BuildContext context) => + _RenderSliverCrossAxisLayoutBuilder(); +} + +/// Pass-through render sliver — geometry, paint, and hit-testing mirror +/// the framework's `_RenderSliverLayoutBuilder`; only [layoutInfo] differs. +class _RenderSliverCrossAxisLayoutBuilder extends RenderSliver + with + RenderObjectWithChildMixin, + RenderObjectWithLayoutCallbackMixin, + RenderAbstractLayoutBuilderMixin { + @override + double get layoutInfo => constraints.crossAxisExtent; + + @override + double childMainAxisPosition(RenderObject child) { + assert(child == this.child); + return 0; + } + + @override + void performLayout() { + runLayoutCallback(); + child?.layout(constraints, parentUsesSize: true); + geometry = child?.geometry ?? SliverGeometry.zero; + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + assert(child == this.child); + // The child's offset is always (0, 0); no transform needed. + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child?.geometry?.visible ?? false) { + context.paintChild(child!, offset); + } + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { + return child != null && + child!.geometry!.hitTestExtent > 0 && + child!.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + } +} diff --git a/test/widgets/sliver_cross_axis_layout_builder_test.dart b/test/widgets/sliver_cross_axis_layout_builder_test.dart new file mode 100644 index 00000000..1ada85a9 --- /dev/null +++ b/test/widgets/sliver_cross_axis_layout_builder_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/sliver_cross_axis_layout_builder.dart'; + +void main() { + testWidgets('builder runs on width change and widget update, never from scrolling', (tester) async { + var builderCalls = 0; + var firstItemBuilds = 0; + double? lastExtent; + + Widget app(double width) => MaterialApp( + home: Center( + child: SizedBox( + width: width, + height: 400, + child: CustomScrollView( + slivers: [ + SliverCrossAxisLayoutBuilder( + builder: (context, crossAxisExtent) { + builderCalls++; + lastExtent = crossAxisExtent; + return SliverList.builder( + itemCount: 100, + itemBuilder: (context, index) { + if (index == 0) { + return Builder( + builder: (context) { + firstItemBuilds++; + return const SizedBox(height: 50); + }, + ); + } + return const SizedBox(height: 50); + }, + ); + }, + ), + ], + ), + ), + ), + ); + + await tester.pumpWidget(app(300)); + expect(builderCalls, 1); + expect(lastExtent, 300); + expect(firstItemBuilds, 1); + + // Scrolling changes SliverConstraints.scrollOffset but not the cross-axis + // extent: the builder must not re-run, and the realized first item (still + // within the cache extent) must not be rebuilt. + await tester.drag(find.byType(CustomScrollView), const Offset(0, -30)); + await tester.pumpAndSettle(); + expect(builderCalls, 1, reason: 'scrolling must not re-invoke the builder'); + expect(firstItemBuilds, 1, reason: 'scrolling must not rebuild realized children'); + + // A real width change re-runs the builder with the new extent. + await tester.pumpWidget(app(250)); + await tester.pumpAndSettle(); + expect(builderCalls, 2); + expect(lastExtent, 250); + + // Updating the widget (new builder closure from a parent rebuild) re-runs + // it even at the same width — data changes must always propagate. + await tester.pumpWidget(app(250)); + await tester.pumpAndSettle(); + expect(builderCalls, 3); + }); +}