From e81daec0d66559ee526bdb5a509c6c0ecce6e19e Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 15 Sep 2026 21:26:30 +0500 Subject: [PATCH 1/4] MOBILE-492: Keep the embedded block alive in a lazy list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block scrolled past the cacheExtent of a ListView was disposed with its row: the widget sent `release`, the native container tore its page down, and every return cost a new web view and a full reload behind a shimmer. The native iOS and RN blocks never behave that way — a view in a scroll is paused off screen, not destroyed. The state now mixes in AutomaticKeepAliveClientMixin, so the list keeps the block — and the platform view with the SDK's page behind it — while the list lives. Off screen the native block pauses itself, on the way back it resumes the same page. `keepAlive` (default true) lets a host with many blocks opt out and pay a reload instead of memory; it is live, so a change takes effect on the block in place. Verified on iOS (ios-sdk 2.16.0-rc) and Android (mobile-sdk 2.16.0-rc): ten scroll-away-and-back cycles build the page once, send no `release`, and add no Inapp.Targeting or Inapp.Show. --- mindbox/README.md | 14 +++ mindbox/lib/src/embedded_block.dart | 36 ++++++- mindbox/test/embedded_block_test.dart | 134 ++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/mindbox/README.md b/mindbox/README.md index b20b041..2b5eac1 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -78,6 +78,20 @@ MindboxEmbeddedBlock( reload. `timeout` is fixed when the block is created — a new value is ignored and reported to the log; give the widget a new `Key` to load a block on a new budget. +In a lazy list — a `ListView`, a `GridView` — the block asks to be kept alive off screen by default, +the way the native blocks behave in a scroll: a block scrolled far away keeps its page, and on the +way back it shows the same content at once, with no reload and no shimmer. The price is memory — +every kept block holds its web page for as long as the list lives. A screen with many blocks can opt +out with `keepAlive: false`, and then the block is disposed with its row like any other widget. + +```dart +ListView.builder( + itemBuilder: (_, index) => index == 0 + ? const MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104) + : ProductRow(index), +) +``` + Available on iOS and Android. On any other platform the block collapses right away and reports `onFail`, so a layout that hides its section on failure behaves the same everywhere. diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index f84c7dd..1f563e4 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -51,6 +51,7 @@ class MindboxEmbeddedBlock extends StatelessWidget { required this.placeSystemName, required this.height, this.timeout, + this.keepAlive = true, this.placeholder, this.errorBuilder, this.onLoad, @@ -88,6 +89,24 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// to the log. Give the widget a new [Key] to load a block on a new budget. final Duration? timeout; + /// Whether the block survives being scrolled out of a lazy list. + /// + /// A `ListView`, a `GridView` or any other lazy sliver builds only what is near the viewport and + /// throws the rest away — a block scrolled far enough would be disposed with its row, and on the + /// way back a *new* block would load its content from scratch: a full cycle with the shimmer on + /// every pass across the screen. The native iOS and Android blocks do not behave that way: a view + /// in a scroll is paused off screen, not destroyed, and its page is shown again as it was. + /// + /// `true` — the default — asks the list to keep the block alive, so it matches the native blocks: + /// off screen its content is paused, and on the way back the same page is shown at once, with no + /// reload, no shimmer and no second [onLoad]. Outside a lazy list the flag changes nothing. + /// + /// The price is memory: every kept block holds its web page for as long as the list lives. A + /// screen with many blocks that is better off paying a reload than holding them all can turn this + /// off, and then the block is disposed with its row exactly as any other widget is. Live: a new + /// value takes effect on the block in place. + final bool keepAlive; + /// Built instead of the SDK shimmer while the block is loading. /// /// Fills the whole place, as the native placeholder does: the widget is given the block's full @@ -126,6 +145,7 @@ class MindboxEmbeddedBlock extends StatelessWidget { placeSystemName: placeSystemName, height: height, timeout: timeout, + keepAlive: keepAlive, placeholder: placeholder, errorBuilder: errorBuilder, onLoad: onLoad, @@ -140,6 +160,7 @@ class _EmbeddedBlock extends StatefulWidget { required this.placeSystemName, required this.height, required this.timeout, + required this.keepAlive, required this.placeholder, required this.errorBuilder, required this.onLoad, @@ -149,6 +170,7 @@ class _EmbeddedBlock extends StatefulWidget { final String placeSystemName; final double height; final Duration? timeout; + final bool keepAlive; final WidgetBuilder? placeholder; final WidgetBuilder? errorBuilder; final VoidCallback? onLoad; @@ -158,7 +180,14 @@ class _EmbeddedBlock extends StatefulWidget { State<_EmbeddedBlock> createState() => _EmbeddedBlockState(); } -class _EmbeddedBlockState extends State<_EmbeddedBlock> { +/// Kept alive in a lazy list by default: the platform view — and the SDK container with its page +/// behind it — is what a reload costs, and a row of a `ListView` is rebuilt on every pass across the +/// screen. Off screen the native block pauses itself (it leaves the window), so keeping it costs +/// memory, not work; see [MindboxEmbeddedBlock.keepAlive]. +class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => widget.keepAlive; + double get _height => widget.height.isFinite ? math.max(0, widget.height) : 0; late final Duration? _creationTimeout; @@ -214,6 +243,9 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override void didUpdateWidget(covariant _EmbeddedBlock oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.keepAlive != widget.keepAlive) { + updateKeepAlive(); + } _warnIfTimeoutIsIgnored(); _pushStandIns(); } @@ -232,6 +264,8 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override Widget build(BuildContext context) { + // The mixin's build is what hands the list the keep-alive handle; its widget is not used. + super.build(context); final Widget? hostLayer = _hostLayer(context); return SizedBox( diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 0230aba..b450522 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -643,4 +643,138 @@ void main() { } }); }); + group('A lazy list', () { + late List methods; + + setUp(() { + methods = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { + if (call.method != 'create') { + return null; + } + + final Map arguments = call.arguments as Map; + final int viewId = arguments['id']! as int; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(embeddedBlockChannelName(viewId)), + (MethodCall call) async { + methods.add(call.method); + return null; + }, + ); + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + // On iOS, where the widget tells the block to stop itself: a `release` in the log is the + // proof that the row took the block down with it. + void testOnIOS(String description, Future Function(WidgetTester) body) { + testWidgets(description, (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + } + + // A list ten screens tall with the block in its first row. The test viewport is 600 logical + // pixels high and the list caches 250 more, so a scroll of a few thousand takes the row far + // past anything the list keeps around on its own. + Future pumpList(WidgetTester tester, {required bool keepAlive}) { + return tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + keepAlive: keepAlive, + ) + : const SizedBox(height: 104), + ), + )); + } + + Future scrollBy(WidgetTester tester, double offset) async { + await tester.drag(find.byType(ListView), Offset(0, -offset)); + await tester.pumpAndSettle(); + } + + int nativeBlocksCreated() => + methods.where((String method) => method == EmbeddedBlockMethods.sync).length; + + testOnIOS('A block scrolled away survives the row and comes back without a reload', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + expect(nativeBlocksCreated(), 1); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock), findsNothing); + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + + await scrollBy(tester, -5000); + + expect(find.byType(MindboxEmbeddedBlock), findsOneWidget); + expect(nativeBlocksCreated(), 1); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + }); + + testOnIOS('A host that opts out gets the block disposed with its row and rebuilt on the way back', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + expect(nativeBlocksCreated(), 1); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); + expect(methods, contains(EmbeddedBlockMethods.release)); + + await scrollBy(tester, -5000); + + expect(find.byType(MindboxEmbeddedBlock), findsOneWidget); + expect(nativeBlocksCreated(), 2); + }); + + testOnIOS('Opting out of keep-alive takes effect on the live block', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); + expect(methods, contains(EmbeddedBlockMethods.release)); + }); + + testOnIOS('Opting back into keep-alive takes effect on the live block', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + + await scrollBy(tester, 5000); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + }); + }); + } From aca500c6ad52d2a4387310ffc629cc4273017a08 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 16 Sep 2026 00:30:45 +0500 Subject: [PATCH 2/4] MOBILE-492: Tell the native block it is hidden when the list parks it off screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block kept alive by a lazy list is not painted off screen, but what the platform does with its view differs. iOS takes the UIView out of the window, so the native container pauses the page on its own. Android keeps the PlatformViewWrapper attached and visible and only stops updating its texture — the container has no way to tell such a block from one in view, so off screen it kept running its page, spending the waiting budget and accounting a show nobody saw. The widget now reads `keptAlive` from the parent data of the nearest sliver child after every frame while it is kept alive, and folds it into the hostVisible signal the wrapper already carries for TickerMode. A parked block is reported hidden — the same pause the block gets behind a pushed route — and shown again on the way back. On iOS the signal is redundant and idempotent. A post-frame callback runs only when frames are produced, so a list standing still costs nothing. Verified on Android (mobile-sdk 2.16.0-rc): ten scroll-away-and-back cycles pause and resume the kept block on every pass with the page built once; before the change the same run produced no pause at all. iOS keeps pausing through the window and builds the page once as before. --- mindbox/lib/src/embedded_block.dart | 74 ++++++++++++++++++++++++++- mindbox/test/embedded_block_test.dart | 50 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 1f563e4..10690af 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -2,6 +2,8 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; @@ -101,6 +103,12 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// off screen its content is paused, and on the way back the same page is shown at once, with no /// reload, no shimmer and no second [onLoad]. Outside a lazy list the flag changes nothing. /// + /// The pause is the widget's doing, not only the platform's: a kept block that the list has + /// scrolled out of view is reported to the native block as hidden, the same way a block behind a + /// pushed route is. On iOS the platform view also leaves the window, on Android it stays attached + /// and would otherwise count as visible — running its page, spending its waiting budget and + /// accounting a show nobody sees. + /// /// The price is memory: every kept block holds its web page for as long as the list lives. A /// screen with many blocks that is better off paying a reload than holding them all can turn this /// off, and then the block is disposed with its row exactly as any other widget is. Live: a new @@ -204,7 +212,15 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC bool? _syncedHasErrorView; bool? _syncedHostVisible; - bool _isHostVisible = true; + bool _isTickerEnabled = true; + + /// The list keeps the block alive, and it is out of view. Read from the sliver's parent data + /// after every frame while [MindboxEmbeddedBlock.keepAlive] is on. + bool _isKeptAliveOffscreen = false; + + bool _isKeptAliveCheckArmed = false; + + bool get _isHostVisible => _isTickerEnabled && !_isKeptAliveOffscreen; bool get _hasPlaceholder => widget.placeholder != null; @@ -221,6 +237,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC _creationTimeout = widget.timeout; _warnIfPlaceIsPadded(); _warnIfHeightReservesNoSpace(); + _armKeptAliveCheck(); if (!_isSupported) { WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { if (!mounted) { @@ -236,7 +253,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC void didChangeDependencies() { super.didChangeDependencies(); // ignore: deprecated_member_use - _isHostVisible = TickerMode.of(context); + _isTickerEnabled = TickerMode.of(context); _pushHostVisible(); } @@ -245,6 +262,14 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC super.didUpdateWidget(oldWidget); if (oldWidget.keepAlive != widget.keepAlive) { updateKeepAlive(); + if (widget.keepAlive) { + _armKeptAliveCheck(); + } else { + // A block that is not kept is disposed when it leaves the list, so off screen it is + // never in a state to hide. + _isKeptAliveOffscreen = false; + _pushHostVisible(); + } } _warnIfTimeoutIsIgnored(); _pushStandIns(); @@ -424,6 +449,51 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC _invoke(channel, EmbeddedBlockMethods.setHostVisible, _isHostVisible); } + /// Whether the list has parked the block off screen. A lazy sliver flips `keptAlive` on the + /// child's parent data while it lays out, so the answer is read once the frame is done. + /// + /// The walk stops at the first ancestor that is a sliver's child; a block outside any lazy list + /// never finds one and is never off screen by this measure. + bool _readKeptAliveOffscreen() { + RenderObject? node = context.findRenderObject(); + while (node != null) { + final ParentData? parentData = node.parentData; + if (parentData is KeepAliveParentDataMixin) { + return parentData.keptAlive; + } + + // `parent` is typed as the abstract node on the oldest Flutter the plugin speaks to, and as + // a render object on the newest — the check reads on both without a cast to warn about. + final Object? parent = node.parent; + node = parent is RenderObject ? parent : null; + } + return false; + } + + /// Re-armed after every frame while the block is kept alive. A post-frame callback runs only + /// when a frame is produced, so a list that stands still costs nothing; a list that scrolls + /// pays a short walk up the render tree per block per frame. + void _armKeptAliveCheck() { + if (_isKeptAliveCheckArmed || !widget.keepAlive || !_isSupported) { + return; + } + + _isKeptAliveCheckArmed = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _isKeptAliveCheckArmed = false; + if (!mounted) { + return; + } + + final bool keptAliveOffscreen = _readKeptAliveOffscreen(); + if (keptAliveOffscreen != _isKeptAliveOffscreen) { + _isKeptAliveOffscreen = keptAliveOffscreen; + _pushHostVisible(); + } + _armKeptAliveCheck(); + }); + } + void _invoke(MethodChannel channel, String method, Object? arguments) { channel.invokeMethod(method, arguments).catchError((Object error) { debugPrint('[MindboxEmbeddedBlock] $method for block "${widget.placeSystemName}" ' diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index b450522..2bcf1ae 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -645,9 +645,11 @@ void main() { }); group('A lazy list', () { late List methods; + late List hostVisible; setUp(() { methods = []; + hostVisible = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { if (call.method != 'create') { @@ -660,6 +662,9 @@ void main() { MethodChannel(embeddedBlockChannelName(viewId)), (MethodCall call) async { methods.add(call.method); + if (call.method == EmbeddedBlockMethods.setHostVisible) { + hostVisible.add(call.arguments as bool); + } return null; }, ); @@ -762,6 +767,51 @@ void main() { expect(methods, contains(EmbeddedBlockMethods.release)); }); + testOnIOS('A kept block scrolled out of view is reported hidden, and shown again on the way back', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + expect(hostVisible, [true]); + + await scrollBy(tester, 5000); + + expect(hostVisible, [true, false]); + + await scrollBy(tester, -5000); + + expect(hostVisible, [true, false, true]); + }); + + testOnIOS('A kept block still in view is not reported hidden by the check', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + + // Short of the cache extent: the row is out of the viewport but still live, and a live row + // is the platform's to pause, not the widget's. + await scrollBy(tester, 150); + await tester.pump(); + await tester.pump(); + + expect(hostVisible, [true]); + }); + + testOnIOS('Outside a lazy list the check never hides the block', (WidgetTester tester) async { + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104), + ], + ), + )); + await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(); + + expect(hostVisible, [true]); + }); + testOnIOS('Opting back into keep-alive takes effect on the live block', (WidgetTester tester) async { await pumpList(tester, keepAlive: false); From ef1ff7959572c687de396f9f5f093ae6ca91f669 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 16 Sep 2026 23:00:34 +0500 Subject: [PATCH 3/4] MOBILE-492: Answer for every enclosing list when reading the parked state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keep-alive request travels past the nearest lazy list to all the others, so a block in a carousel inside a feed is parked by the feed while the carousel's own parent data still says the block is in place. The walk used to stop at that first answer and never reported such a block hidden — on Android its page kept running off screen one nesting level up from the case the previous commit fixed. It now goes to the root and reports parked if any enclosing list says so. Also: a platform without a native block no longer asks to be kept alive; turning keepAlive off on a parked block no longer shows it for its last frame — the check already armed for that frame finds it gone, or in place if the row came back; docs name the real price of the default (the whole row in every enclosing list, not just the page); CHANGELOG mentions the default. Tests: a carousel inside a feed, opting out while parked, a disabled TickerMode through parking and return. Verified on an Android emulator with the demo's Scrolls scenario: a block in a tab's list is paused when TabBarView parks the tab and resumed when it comes back. --- mindbox/CHANGELOG.md | 2 +- mindbox/README.md | 5 +- mindbox/lib/src/embedded_block.dart | 40 ++++++++------ mindbox/test/embedded_block_test.dart | 79 +++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/mindbox/CHANGELOG.md b/mindbox/CHANGELOG.md index 48b3a82..1fd38a5 100644 --- a/mindbox/CHANGELOG.md +++ b/mindbox/CHANGELOG.md @@ -1,6 +1,6 @@ ## Unreleased -* Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel. +* Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel. In a lazy list the block is kept alive off screen by default (`keepAlive`), so scrolling back shows the same page without a reload. ## 2.15.2 diff --git a/mindbox/README.md b/mindbox/README.md index 2b5eac1..f503d84 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -81,8 +81,9 @@ log; give the widget a new `Key` to load a block on a new budget. In a lazy list — a `ListView`, a `GridView` — the block asks to be kept alive off screen by default, the way the native blocks behave in a scroll: a block scrolled far away keeps its page, and on the way back it shows the same content at once, with no reload and no shimmer. The price is memory — -every kept block holds its web page for as long as the list lives. A screen with many blocks can opt -out with `keepAlive: false`, and then the block is disposed with its row like any other widget. +every kept block holds its web page for as long as the list lives, and the whole row it stands in is +kept with it. A screen with many blocks can opt out with `keepAlive: false`, and then the block is +disposed with its row like any other widget. ```dart ListView.builder( diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 10690af..7cc1aba 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -109,10 +109,12 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// and would otherwise count as visible — running its page, spending its waiting budget and /// accounting a show nobody sees. /// - /// The price is memory: every kept block holds its web page for as long as the list lives. A - /// screen with many blocks that is better off paying a reload than holding them all can turn this - /// off, and then the block is disposed with its row exactly as any other widget is. Live: a new - /// value takes effect on the block in place. + /// The price is memory: every kept block holds its web page for as long as the list lives, and + /// the request keeps the whole row alive — the row's own widgets with it — in every lazy list + /// the block stands in, a carousel inside a feed included. A screen with many blocks that is + /// better off paying a reload than holding them all can turn this off, and then the block is + /// disposed with its row exactly as any other widget is. Live: a new value takes effect on the + /// block in place. final bool keepAlive; /// Built instead of the SDK shimmer while the block is loading. @@ -190,11 +192,12 @@ class _EmbeddedBlock extends StatefulWidget { /// Kept alive in a lazy list by default: the platform view — and the SDK container with its page /// behind it — is what a reload costs, and a row of a `ListView` is rebuilt on every pass across the -/// screen. Off screen the native block pauses itself (it leaves the window), so keeping it costs -/// memory, not work; see [MindboxEmbeddedBlock.keepAlive]. +/// screen. Off screen the block is paused rather than destroyed — by the window on iOS, and by the +/// hidden signal this widget sends on Android — so keeping it costs memory, not work; see +/// [MindboxEmbeddedBlock.keepAlive]. A platform without a native block has nothing worth keeping. class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveClientMixin { @override - bool get wantKeepAlive => widget.keepAlive; + bool get wantKeepAlive => widget.keepAlive && _isSupported; double get _height => widget.height.isFinite ? math.max(0, widget.height) : 0; @@ -264,12 +267,12 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC updateKeepAlive(); if (widget.keepAlive) { _armKeptAliveCheck(); - } else { - // A block that is not kept is disposed when it leaves the list, so off screen it is - // never in a state to hide. - _isKeptAliveOffscreen = false; - _pushHostVisible(); } + // Turning it off needs nothing more. A block on screen was never hidden by the check. A + // parked one — the list rebuilds those too — is collected in this very frame's layout + // now that nothing keeps it, and the check already armed for this frame fires once more + // and stops: it finds the block gone, or, if the row came back in the same frame, finds + // it in place and lifts the flag. } _warnIfTimeoutIsIgnored(); _pushStandIns(); @@ -449,17 +452,20 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC _invoke(channel, EmbeddedBlockMethods.setHostVisible, _isHostVisible); } - /// Whether the list has parked the block off screen. A lazy sliver flips `keptAlive` on the + /// Whether a list has parked the block off screen. A lazy sliver flips `keptAlive` on the /// child's parent data while it lays out, so the answer is read once the frame is done. /// - /// The walk stops at the first ancestor that is a sliver's child; a block outside any lazy list - /// never finds one and is never off screen by this measure. + /// The walk goes all the way up and answers for *every* enclosing lazy list, not the nearest + /// one: the keep-alive request travels past the first list to all the others, so a carousel + /// inside a feed is parked by the feed while the carousel's own parent data still says the + /// block is in place. A block outside any lazy list finds nothing and is never off screen by + /// this measure. bool _readKeptAliveOffscreen() { RenderObject? node = context.findRenderObject(); while (node != null) { final ParentData? parentData = node.parentData; - if (parentData is KeepAliveParentDataMixin) { - return parentData.keptAlive; + if (parentData is KeepAliveParentDataMixin && parentData.keptAlive) { + return true; } // `parent` is typed as the abstract node on the oldest Flutter the plugin speaks to, and as diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 2bcf1ae..f5c63e2 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -812,6 +812,85 @@ void main() { expect(hostVisible, [true]); }); + testOnIOS('A block in a carousel inside a feed is reported hidden when the feed parks the row', + (WidgetTester tester) async { + const Key feed = Key('feed'); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + key: feed, + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? SizedBox( + height: 104, + child: ListView( + scrollDirection: Axis.horizontal, + children: const [ + SizedBox( + width: 300, + child: MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104), + ), + SizedBox(width: 300), + ], + ), + ) + : const SizedBox(height: 104), + ), + )); + await tester.pumpAndSettle(); + expect(hostVisible, [true]); + + // The carousel's own parent data never parks the block — the feed does, one level up. + await tester.drag(find.byKey(feed), const Offset(0, -5000)); + await tester.pumpAndSettle(); + + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(hostVisible, [true, false]); + + await tester.drag(find.byKey(feed), const Offset(0, 5000)); + await tester.pumpAndSettle(); + + expect(hostVisible, [true, false, true]); + }); + + testOnIOS('Opting out while parked lets the list drop the block without showing it first', + (WidgetTester tester) async { + await pumpList(tester, keepAlive: true); + await tester.pumpAndSettle(); + await scrollBy(tester, 5000); + expect(hostVisible, [true, false]); + + await pumpList(tester, keepAlive: false); + await tester.pumpAndSettle(); + + expect(hostVisible, [true, false]); + expect(methods, contains(EmbeddedBlockMethods.release)); + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); + }); + + testOnIOS('A block behind a disabled TickerMode stays hidden through parking and return', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TickerMode( + enabled: false, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? const MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104) + : const SizedBox(height: 104), + ), + ), + )); + await tester.pumpAndSettle(); + expect(hostVisible, [false]); + + await scrollBy(tester, 5000); + await scrollBy(tester, -5000); + + expect(hostVisible, [false]); + }); + testOnIOS('Opting back into keep-alive takes effect on the live block', (WidgetTester tester) async { await pumpList(tester, keepAlive: false); From 9a4db22fce7f6d9f84d2133d6919ce38a6a80ce5 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 17 Sep 2026 14:10:01 +0500 Subject: [PATCH 4/4] MOBILE-492: Keep checking the parked state for as long as the block is mounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check used to run only while the block's own keepAlive was on. But the block's request is not the only thing that can park its row: any keep-alive client in the row does — a host's stateful row widget, another block. A block that opted out, or turned the flag off while parked, in such a row stayed mounted and parked with the check gone, and once the row came back nothing lifted the hidden flag — the native block stayed paused for good. Now the check runs whatever the flag says; the flag only decides whether the block itself asks the list to keep it. Tests run on both platforms now — the keep-alive and hidden/shown paths are the same Dart on both, only the teardown differs, so `release` is asserted on iOS alone — and two new cases cover a row kept by someone else. --- mindbox/lib/src/embedded_block.dart | 26 +++-- mindbox/test/embedded_block_test.dart | 132 ++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 32 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 7cc1aba..ccd179b 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -217,8 +217,8 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC bool _isTickerEnabled = true; - /// The list keeps the block alive, and it is out of view. Read from the sliver's parent data - /// after every frame while [MindboxEmbeddedBlock.keepAlive] is on. + /// A list keeps the block's row alive, and it is out of view. Read from the slivers' parent + /// data after every frame for as long as the block is mounted. bool _isKeptAliveOffscreen = false; bool _isKeptAliveCheckArmed = false; @@ -265,14 +265,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC super.didUpdateWidget(oldWidget); if (oldWidget.keepAlive != widget.keepAlive) { updateKeepAlive(); - if (widget.keepAlive) { - _armKeptAliveCheck(); - } - // Turning it off needs nothing more. A block on screen was never hidden by the check. A - // parked one — the list rebuilds those too — is collected in this very frame's layout - // now that nothing keeps it, and the check already armed for this frame fires once more - // and stops: it finds the block gone, or, if the row came back in the same frame, finds - // it in place and lifts the flag. } _warnIfTimeoutIsIgnored(); _pushStandIns(); @@ -476,11 +468,17 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> with AutomaticKeepAliveC return false; } - /// Re-armed after every frame while the block is kept alive. A post-frame callback runs only - /// when a frame is produced, so a list that stands still costs nothing; a list that scrolls - /// pays a short walk up the render tree per block per frame. + /// Re-armed after every frame for as long as the block is mounted, whatever its own + /// [MindboxEmbeddedBlock.keepAlive] says: the block's request is not the only thing that can + /// park its row — any keep-alive client in the row does, another block among them — and a + /// block parked by someone else has to be hidden and shown all the same. Tying the check to + /// the block's own flag would also leave a block that turned the flag off while parked hidden + /// for good once its row came back. + /// + /// A post-frame callback runs only when a frame is produced, so a list that stands still costs + /// nothing; a list that scrolls pays a short walk up the render tree per block per frame. void _armKeptAliveCheck() { - if (_isKeptAliveCheckArmed || !widget.keepAlive || !_isSupported) { + if (_isKeptAliveCheckArmed || !_isSupported) { return; } diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index f5c63e2..0b244be 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -677,11 +677,10 @@ void main() { .setMockMethodCallHandler(SystemChannels.platform_views, null); }); - // On iOS, where the widget tells the block to stop itself: a `release` in the log is the - // proof that the row took the block down with it. - void testOnIOS(String description, Future Function(WidgetTester) body) { + void testOn(TargetPlatform platform, String description, + Future Function(WidgetTester) body) { testWidgets(description, (WidgetTester tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + debugDefaultTargetPlatformOverride = platform; try { await body(tester); } finally { @@ -690,6 +689,22 @@ void main() { }); } + // Both platforms host a native block, and the widget's keep-alive and hidden/shown paths + // are the same Dart on both — only the teardown differs: on iOS the widget sends `release` + // itself, on Android the platform view's own dispose hook does, so `release` is asserted + // on iOS only. + void testOnBoth(String description, Future Function(WidgetTester) body) { + for (final TargetPlatform platform in [ + TargetPlatform.iOS, + TargetPlatform.android, + ]) { + testOn(platform, '$description (${platform.name})', body); + } + } + + // A row that asks to be kept alive on its own, the way a host's stateful row widget might. + Widget keptRow({required Widget child}) => _KeptAliveRow(child: child); + // A list ten screens tall with the block in its first row. The test viewport is 600 logical // pixels high and the list caches 250 more, so a scroll of a few thousand takes the row far // past anything the list keeps around on its own. @@ -717,7 +732,7 @@ void main() { int nativeBlocksCreated() => methods.where((String method) => method == EmbeddedBlockMethods.sync).length; - testOnIOS('A block scrolled away survives the row and comes back without a reload', + testOnBoth('A block scrolled away survives the row and comes back without a reload', (WidgetTester tester) async { await pumpList(tester, keepAlive: true); await tester.pumpAndSettle(); @@ -736,7 +751,7 @@ void main() { expect(methods, isNot(contains(EmbeddedBlockMethods.release))); }); - testOnIOS('A host that opts out gets the block disposed with its row and rebuilt on the way back', + testOnBoth('A host that opts out gets the block disposed with its row and rebuilt on the way back', (WidgetTester tester) async { await pumpList(tester, keepAlive: false); await tester.pumpAndSettle(); @@ -745,7 +760,9 @@ void main() { await scrollBy(tester, 5000); expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); - expect(methods, contains(EmbeddedBlockMethods.release)); + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(methods, contains(EmbeddedBlockMethods.release)); + } await scrollBy(tester, -5000); @@ -753,7 +770,7 @@ void main() { expect(nativeBlocksCreated(), 2); }); - testOnIOS('Opting out of keep-alive takes effect on the live block', + testOnBoth('Opting out of keep-alive takes effect on the live block', (WidgetTester tester) async { await pumpList(tester, keepAlive: true); await tester.pumpAndSettle(); @@ -764,10 +781,12 @@ void main() { await scrollBy(tester, 5000); expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); - expect(methods, contains(EmbeddedBlockMethods.release)); + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(methods, contains(EmbeddedBlockMethods.release)); + } }); - testOnIOS('A kept block scrolled out of view is reported hidden, and shown again on the way back', + testOnBoth('A kept block scrolled out of view is reported hidden, and shown again on the way back', (WidgetTester tester) async { await pumpList(tester, keepAlive: true); await tester.pumpAndSettle(); @@ -782,13 +801,13 @@ void main() { expect(hostVisible, [true, false, true]); }); - testOnIOS('A kept block still in view is not reported hidden by the check', + testOnBoth('A kept block still in view is not reported hidden by the check', (WidgetTester tester) async { await pumpList(tester, keepAlive: true); await tester.pumpAndSettle(); // Short of the cache extent: the row is out of the viewport but still live, and a live row - // is the platform's to pause, not the widget's. + // is for the platform to pause, not the widget. await scrollBy(tester, 150); await tester.pump(); await tester.pump(); @@ -796,7 +815,7 @@ void main() { expect(hostVisible, [true]); }); - testOnIOS('Outside a lazy list the check never hides the block', (WidgetTester tester) async { + testOnBoth('Outside a lazy list the check never hides the block', (WidgetTester tester) async { await tester.pumpWidget(const Directionality( textDirection: TextDirection.ltr, child: Column( @@ -812,7 +831,7 @@ void main() { expect(hostVisible, [true]); }); - testOnIOS('A block in a carousel inside a feed is reported hidden when the feed parks the row', + testOnBoth('A block in a carousel inside a feed is reported hidden when the feed parks the row', (WidgetTester tester) async { const Key feed = Key('feed'); await tester.pumpWidget(Directionality( @@ -853,7 +872,7 @@ void main() { expect(hostVisible, [true, false, true]); }); - testOnIOS('Opting out while parked lets the list drop the block without showing it first', + testOnBoth('Opting out while parked lets the list drop the block without showing it first', (WidgetTester tester) async { await pumpList(tester, keepAlive: true); await tester.pumpAndSettle(); @@ -864,11 +883,13 @@ void main() { await tester.pumpAndSettle(); expect(hostVisible, [true, false]); - expect(methods, contains(EmbeddedBlockMethods.release)); + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(methods, contains(EmbeddedBlockMethods.release)); + } expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsNothing); }); - testOnIOS('A block behind a disabled TickerMode stays hidden through parking and return', + testOnBoth('A block behind a disabled TickerMode stays hidden through parking and return', (WidgetTester tester) async { await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, @@ -891,7 +912,61 @@ void main() { expect(hostVisible, [false]); }); - testOnIOS('Opting back into keep-alive takes effect on the live block', + Future pumpKeptRowList(WidgetTester tester, {required bool keepAlive}) { + return tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => index == 0 + ? keptRow( + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + keepAlive: keepAlive, + ), + ) + : const SizedBox(height: 104), + ), + )); + } + + testOnBoth('A block that opted out but sits in a row someone else keeps is still hidden and shown', + (WidgetTester tester) async { + await pumpKeptRowList(tester, keepAlive: false); + await tester.pumpAndSettle(); + expect(hostVisible, [true]); + + await scrollBy(tester, 5000); + + // The row's own client keeps it, so the block survives without asking — and must not run. + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + expect(hostVisible, [true, false]); + + await scrollBy(tester, -5000); + + expect(hostVisible, [true, false, true]); + expect(nativeBlocksCreated(), 1); + }); + + testOnBoth('Opting out while parked in a row someone else keeps does not leave the block hidden', + (WidgetTester tester) async { + await pumpKeptRowList(tester, keepAlive: true); + await tester.pumpAndSettle(); + await scrollBy(tester, 5000); + expect(hostVisible, [true, false]); + + await pumpKeptRowList(tester, keepAlive: false); + await tester.pumpAndSettle(); + expect(find.byType(MindboxEmbeddedBlock, skipOffstage: false), findsOneWidget); + + await scrollBy(tester, -5000); + + expect(hostVisible, [true, false, true]); + expect(nativeBlocksCreated(), 1); + }); + + testOnBoth('Opting back into keep-alive takes effect on the live block', (WidgetTester tester) async { await pumpList(tester, keepAlive: false); await tester.pumpAndSettle(); @@ -907,3 +982,24 @@ void main() { }); } + +/// A list row with a keep-alive client of its own, as a host's stateful row widget might have. +class _KeptAliveRow extends StatefulWidget { + const _KeptAliveRow({required this.child}); + + final Widget child; + + @override + State<_KeptAliveRow> createState() => _KeptAliveRowState(); +} + +class _KeptAliveRowState extends State<_KeptAliveRow> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +}