Skip to content

MOBILE-492: Keep the embedded block alive in a lazy list - #218

Merged
Vailence merged 4 commits into
mission/storiesfrom
feature/MOBILE-492
Sep 21, 2026
Merged

Vailence merged 4 commits into
mission/storiesfrom
feature/MOBILE-492

Conversation

@Vailence

Copy link
Copy Markdown
Collaborator

Problem

A MindboxEmbeddedBlock in a ListView was disposed with its row past the cacheExtent. The widget sent release, the native container tore its page down, and every return rebuilt the web view behind a shimmer. Native iOS and RN blocks never behave that way: a view in a scroll is paused off screen, not destroyed.

Reproduced on both platforms with the Pushok long-list scenario, ten scroll-away-and-back cycles of one block:

iOS (ticket) Android (emulator, mobile-sdk 2.16.0-rc)
page builds 10 11 (Creating WebView 36 across three blocks)
release per pass yes yes (Released by the host wrapper 36)
time to content per rebuild 0.59 / 0.64 / 0.71 s 0.64 / 1.63 / 3.72 s

Change

_EmbeddedBlockState mixes in AutomaticKeepAliveClientMixin (e81daec). The list keeps the block, and the platform view with the SDK page behind it, for as long as the list lives. New parameter keepAlive (default true, live) lets a host with many blocks opt out and pay a reload instead of memory. Hosts change nothing.

A parked block is reported hidden (aca500c, ef1ff79). On iOS Flutter takes the UIView out of the window and the container pauses on its own. On Android Flutter keeps the PlatformViewWrapper attached and visible (texture layer composition) and only stops updating its texture, so the container had no way to tell a parked block from one in view: its page kept running, the waiting budget kept counting, a show could be accounted off screen. The widget now reads keptAlive from the parent data of every enclosing lazy list after each frame (a post-frame callback that re-arms itself and never schedules a frame) and folds it into the setHostVisible signal the wrapper already carries for TickerMode. Every enclosing list is asked, not the nearest: the keep-alive request travels past the first list, so a carousel inside a feed, or a list inside a TabBarView tab, is parked one level up.

Verification

Same scenario, keepAlive: true, ten cycles:

iOS Android
page builds 1 per block 1 per block (Creating WebView 3)
release 0 0
pause / resume per pass left the window / back on screen, resuming 20 / 19 was hidden by the host wrapperOff screen, pausing content 40, shownOn screen, starting content 38
shimmer on return none none
new Inapp.Targeting / Inapp.Show 0 0

Before the hidden signal the same Android run produced no pause at all (Off screen, pausing content 0). Nested case checked live with the Scrolls scenario: a block in a tab's ListView is paused when TabBarView parks the tab and resumed on return. keepAlive: false on both platforms goes back to the old dispose-and-rebuild path.

Widget tests: 31 (10 new: lazy list survive / opt-out / live flag both ways, hidden and shown through parking, in-view and out-of-list guards, carousel inside a feed, opt-out while parked, disabled TickerMode). Flutter ≥3.0 compatibility of the new APIs checked (SchedulerBinding.instance, RenderObject.parent typing, KeepAliveParentDataMixin.keptAlive).

Out of scope

A block built inside the cacheExtent but not yet in view is live, not parked, so it has no keptAlive; on Android it counts as visible and loads (pre-existing, unrelated to keep-alive). Needs a viewport-intersection check; belongs with MOBILE-493.

Demo: mindbox-cloud flutter-app feature/MOBILE-492 (GitLab) reworks the long-list scenario so the switch is the widget's own keepAlive.

🤖 Generated with Claude Code

Vailence added 3 commits September 15, 2026 21:26
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.
… off screen

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.
…tate

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate implementation and Android coverage issues remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds lazy-list keep-alive support for MindboxEmbeddedBlock, preserving native pages while rows are parked off screen.

Changes:

  • Adds live keepAlive configuration, enabled by default.
  • Detects parked blocks and reports host visibility changes.
  • Adds documentation, changelog updates, and lifecycle tests.
File summaries
File Reviewed changes and final comments
mindbox/lib/src/embedded_block.dart Implements keep-alive and visibility tracking. Moderate findings remain for parked opt-out handling and hosts disabling automatic keep-alives.
mindbox/test/embedded_block_test.dart Adds lazy-list lifecycle tests. Moderate finding: Android coverage is needed; nit: one comment is ungrammatical.
mindbox/README.md Documents the new option and tradeoffs.
mindbox/CHANGELOG.md Records the new behavior.
Review details

Suppressed comments (2)

mindbox/lib/src/embedded_block.dart:487

  • This callback is re-registered for every kept block and runs on every frame produced by any animation or ticker, not only while this list scrolls. A long feed with many kept blocks therefore performs a render-tree walk per block per frame even when the feed is stationary; please avoid a permanent callback per block or gate checks to relevant scroll/layout frames.
    if (_isKeptAliveCheckArmed || !widget.keepAlive || !_isSupported) {
      return;
    }

    _isKeptAliveCheckArmed = true;

mindbox/lib/src/embedded_block.dart:100

  • AutomaticKeepAliveClientMixin only works when the enclosing sliver delegate inserts AutomaticKeepAlive (for example, addAutomaticKeepAlives remains true). With ListView.builder(addAutomaticKeepAlives: false) or a custom delegate that disables it, the documented default silently falls back to disposal and reload, despite this text promising the behavior for any lazy sliver. Either use an explicit KeepAlive wrapper or document this host requirement.
  /// 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.
  • Files reviewed: 4/4 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread mindbox/lib/src/embedded_block.dart Outdated
Comment thread mindbox/test/embedded_block_test.dart Outdated
Comment thread mindbox/test/embedded_block_test.dart Outdated
…s mounted

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.
@Vailence
Vailence merged commit c822bf4 into mission/stories Sep 21, 2026
8 checks passed
@Vailence
Vailence deleted the feature/MOBILE-492 branch September 21, 2026 07:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants