Skip to content

MOBILE-557: Три исхода, стратегия загрузки и анимация появления встроенного блока - #224

Merged
Vailence merged 11 commits into
mission/storiesfrom
feature/MOBILE-557
Oct 6, 2026
Merged

Vailence merged 11 commits into
mission/storiesfrom
feature/MOBILE-557

Conversation

@Vailence

@Vailence Vailence commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Зачем

Довести MindboxEmbeddedBlock до API нативных блоков из MOBILE-516 / MOBILE-556: логика уже готова в iOS- и Android-SDK, задача React Native — провести её в кроссплатформу без своей интерпретации.

Что меняется

Три исхода вместо двух. onLoad, onEmpty и onFail(reason) — те же, что у SwiftUI, Compose и Flutter. Пустое место больше не маскируется под ошибку. Причина — строка из MindboxEmbeddedBlockFailReason (networkError, internalError), для логов и аналитики, с запасом на новые значения. Нативные половины шлют один ивент onBlockOutcome с полем reason, вместо двух пустых.

loadingStrategy и animatesReveal. automatic (по умолчанию) / placeholder / hidden, фиксируются при создании блока, как timeoutMs. Блок, ждавший скрытым, раскрывается на высоту за время анимации SDK: нативный блок сам решает, анимировать ли это раскрытие (isRevealAnimated, все гейты у него), JS только анимирует высоту слота по его слову. Нативная вьюха живёт внутри обёртки полной высоты под обрезанным слотом, иначе блок нулевого размера никогда бы не построился.

Первый вид automatic-блока отдаёт сам нативный блок. Отдельный канал embeddedBlockInitialAppearance через модуль MindboxSdk убран: нативная вьюха строится сразу, как только у неё есть фрейм, и отчёт о стартовом виде доходит до JS в том же маунте, не позже, чем мог бы ответ модуля. Из-за этого всплыла и починена ошибка в iOS-хосте: он подписывался на блок в init, когда onAppearance ещё не был присвоен, и стартовый отчёт терялся — до JS он доходил только побочно, через повторный .loading при старте контента, а блок с active={false} при маунте не резервировал плейсхолдер до активации экрана. Теперь оба замыкания идут параметрами в init хоста, под Fabric и Paper. Заодно удалены недостижимые буферы _pending* во Fabric-вьюхе: эмиттер всегда установлен до finalizeUpdates:.

Пробелы вокруг имени места — забота нативного блока, компонент передаёт имя как есть и предупреждает только о пустом.

Как проверялось

  • yarn lint, yarn test — 76/76.
  • iOS: таргет MindboxSdk из Pods-проекта приложения собран для симулятора против локального ios-sdk на ветке feature/MOBILE-556.
  • Android: против локальной сборки android-sdk с origin/mission/stories через MINDBOX_ANDROID_SDK_VERSION.
  • Демо в react-native-app (ветка feature/MOBILE-557): все три исхода на локальном моке, automatic/placeholder/hidden со сбросом памяти места, анимация раскрытия.

Открытые вопросы

Релиз только после нативов. mission/stories уже пинит Mindbox и mobile-sdk на 2.16.0-rc, но опубликованные теги 2.16.0-rc обеих платформ ещё не содержат нужного API (isRevealAnimated, revealAnimationDuration, onEmpty, loadingStrategy): на iOS оно на feature/MOBILE-556, на Android — на mission/stories без релиза. Без локальных сборок пакет не соберётся; пины надо выставить на реальные версии нативов при их выходе, и об этом стоит сказать в CHANGELOG.

Нет нативного теста на доставку первого вида. JS-тесты эмулируют onAppearanceChange сами, так что порядок подписки в хосте ничем, кроме кода, не закреплён.

🤖 Generated with Claude Code

Vailence added 6 commits October 1, 2026 21:56
… onFail(reason)

The native blocks report three outcomes since MOBILE-504 (iOS) and MOBILE-505
(Android): the content is shown, the place has nothing to show, or the block
could not be shown, with a reason. The component now reports the same three.

`onEmpty` is new and carries no reason. `onFail` takes a
`MindboxEmbeddedBlockFailReason` — a string with the `networkError` and
`internalError` constants, the same raw values as on the native side, open to
words a later SDK adds. The two outcome events of the native component are
folded into one, `onBlockOutcome`, carrying the outcome word and the reason;
both native halves implement the new listener and delegate methods: the
Android one no longer compiles against the old `onFail(view)`, the iOS one
would otherwise keep compiling against a protocol whose default implementation
silently swallows the failure.

Outcomes are still deduplicated by kind: a failure that repeats with another
reason is the same outcome. A platform without a native block collapses and
reports `internalError`, which the component did not do before. The empty
`MindboxEmbeddedBlockFailure` placeholder type goes: the reason is what it was
reserved for.
… place's memory, and reveal with the SDK's animation

The native blocks take a loading strategy since MOBILE-516 (iOS) and
MOBILE-517 (Android): `automatic` — the default — keeps the block hidden
until the place has shown content once on this device and puts a
placeholder there from then on, `placeholder` takes the space up front,
`hidden` never takes it before the content. Content is revealed with the
SDK's own animation unless `animatesReveal` is off. The component takes both,
fixed at creation like `timeoutMs`, and hands them to the native block.

Three things the JS side has to do itself:

The first look. Both natives decide it synchronously, before the container
exists, through a static `initialAppearance`; JS cannot reach anything
synchronously. `placeholder` and `hidden` are decided from the strategy
alone. `automatic` asks the `MindboxSdk` native module — the one module the
package already has, on both renderers — through a new
`embeddedBlockInitialAppearance` method that the JS side does not export, and
takes no space until it answers: a place that never showed content must not
flash reserved space, and one that has gets its placeholder a frame late
rather than a frame early. The native block's own report, once the native
view exists, outranks a later answer.

The slot. A block that waits hidden used to be impossible: both native halves
build the SDK container only once their view has a frame, and the native view
sat in a slot of zero. The layout now gives the slot — 0 for a collapsed
block — and the block inside keeps its full height under the slot's clip, so
it runs its whole cycle unseen, as the native blocks do.

The growth. The container fades the content in on its own; the growth of a
block that waited hidden is the wrapper's, as in SwiftUI, Compose and Flutter.
The native block says whether the change is the animated reveal — it owns the
gates: animatesReveal, Reduce Motion, "only the arrival of content" — and how
long it takes (`animated` and `revealDurationMs` on the appearance event;
Android's isRevealAnimated and REVEAL_ANIMATION_DURATION_MS, the same two
added to iOS as @_spi in ios-sdk feature/MOBILE-556). The slot then grows from
0 to the height over that duration, an Animated value on the JS side; every
other change lands at once.
…ck's to ignore

iOS strips the whitespace since MOBILE-419, and Android's PlaceKey trims it
too, so a name pasted with a stray space finds its place on both. The
component never trimmed or warned about it — the test that the name reaches
the native block as given stands — and its doc now says so the way the
Flutter widget does, rather than describing what the SDK does with it.
…rst look

An `automatic` block asked the `MindboxSdk` module what it starts with — the
place's memory, read through the native `initialAppearance` — because the
native view was believed to stay unbuilt while the slot is collapsed. It does
not: the view inside the slot keeps the block's full height, both native
halves build the block as soon as they have a frame, and the block reports its
look the moment it is observed. That report reaches JS in the same mount, no
later than the module's promise could, with the same word read from the same
memory; `hasHeardFromNative` existed only to throw the module's answer away.

The channel goes — the TS module, the `@ReactMethod` and the `@objc` method
with its `RCT_EXTERN_METHOD`, the SPI import the Swift module needed for it —
and the first-look tests drive the native report instead of a mocked promise.
…rst look is not lost

`MindboxEmbeddedBlockHost` subscribed to the block inside `init`, and the SDK
answers a subscription at once with the current look — for an `automatic`
block, the place's memory. `onAppearance` was assigned by the component view
only after `init` returned, so that first report went to nobody. JS learned
the look only through the `.loading` the provider re-emits when the block
enters a window and starts, and a block mounted with `active={false}` did not
start: its remembered placeholder waited for the screen to become active,
while Android, whose emitters are wired before the block exists, showed it at
once.

Both callbacks are now parameters of the host's initializer and non-optional
`let`s, with `isTornDown` as the one guard after teardown; the observer is set
last in `init`. Both renderers pass their blocks in: under Fabric the event
emitter is set before `finalizeUpdates:`, under Paper the event blocks are
props set before `didSetProps:` and any layout pass.

With the emitter always in place when the host is built, the pending-event
buffers of the Fabric view had no path left; they are gone, and the emit
methods keep a plain early return that states the invariant.

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.

Copilot review overview

🟡 Changes recommended

Declared native dependencies lack required APIs, and Android event coalescing can discard outcome and appearance transitions.

Review effort: Balanced
Findings: 2 High severity · 2 Medium severity

Open (4)
What changed in this PR

Aligns MindboxEmbeddedBlock with the native SDK APIs for outcomes, loading strategies, and reveal animations.

Changes:

  • Adds three outcome callbacks and failure reasons.
  • Bridges loading strategies and native-controlled reveal animations.
  • Fixes initial iOS appearance delivery and expands tests and documentation.
File Description
src/​MindboxEmbeddedBlockNativeComponent.ts Updates native props and event payloads.
src/​MindboxEmbeddedBlockLoadingStrategy.ts Defines loading strategies.
src/​MindboxEmbeddedBlockFailReason.ts Defines extensible failure reasons.
src/​MindboxEmbeddedBlock.tsx Implements callbacks, strategies, and animated height.
src/​index.tsx Exports the updated public API.
src/​__tests__/​MindboxEmbeddedBlock.unsupported.test.tsx Tests unsupported-platform behavior.
src/​__tests__/​MindboxEmbeddedBlock.test.tsx Tests outcomes, strategies, and reveal behavior.
README.md Documents the updated component API.
ios/​EmbeddedBlock/​MindboxEmbeddedBlockView.mm Bridges new props and preserves initial appearance delivery.
ios/​EmbeddedBlock/​MindboxEmbeddedBlockHost.swift Integrates updated native callbacks and configuration.
ios/​EmbeddedBlock/​EmbeddedBlockWire.swift Maps iOS values to bridge payloads.
CHANGELOG.md Records embedded-block features.
android/​src/​main/​java/​com/​mindboxsdk/​embedded/​MindboxEmbeddedBlockViewManager.kt Bridges new props and events.
android/​src/​main/​java/​com/​mindboxsdk/​embedded/​MindboxEmbeddedBlockHostView.kt Integrates native strategies and outcomes.
android/​src/​main/​java/​com/​mindboxsdk/​embedded/​MindboxEmbeddedBlockEvents.kt Adds outcome and animation payloads.
android/​src/​main/​java/​com/​mindboxsdk/​embedded/​EmbeddedBlockWire.kt Maps Android values to bridge payloads.

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

Comment thread ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift
…ck out of coalescing

React Native's `Event` coalesces by default: same-named events of one view
that are still queued when a frame is dispatched are folded into the last one,
on Paper in `EventDispatcherImpl` and under Fabric through the `canCoalesce`
flag handed to the emitter. The JS side reads every change — a reveal is
content arriving where a placeholder or nothing stood, and every changed
outcome is promised to the host — so a report folded into the next one would
be a report lost. iOS never coalesced these: direct events and the generated
Fabric emitter dispatch each one.

@justSmK justSmK left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Первый круг. Прогнал: jest 76, eslint чистый, tsc по src/ чистый — ошибки только в example/, его PR не трогает. Ключевые тесты краснеют на своих поломках, проверил мутациями: 7 из 8, оставшаяся эквивалентна коду. Нативные половины не собирал, сигнатуры сверил с ios-sdk #783 и android-sdk mission/stories. Починку порядка в iOS-хосте и удаление _pending* проверил по RCTMountingManager — верно.

Нужны три правки. Свой placeholder пропадает рывком, когда натив проявляет контент. Пустое имя обещает onFail, а приходит onEmpty. CHANGELOG молчит о ломающих изменениях для тех, кто уже на 2.16.0-rc. Остальное в комментариях — мелочи.

Вне кода:

  • В задаче MOBILE-557 раздел «Имплементация» описывает embeddedBlockInitialAppearance, а метод удалён в 122511f.
  • Дока error молчит, что добавленный позже error не раскрывает уже схлопнутый блок. Так было и до PR, во Flutter это написано.
  • Пункт DoD про сцену в Пушке и видео — в react-native-app !89, здесь не смотрел.

Comment thread src/MindboxEmbeddedBlock.tsx Outdated
Comment thread src/MindboxEmbeddedBlock.tsx Outdated
Comment thread CHANGELOG.md Outdated
Comment thread src/MindboxEmbeddedBlock.tsx
Comment thread src/MindboxEmbeddedBlock.tsx Outdated
setAppearance(next)
if (opens && revealDurationMs != null && revealDurationMs > 0) {
reveal.setValue(0)
Animated.timing(reveal, { toValue: 1, duration: revealDurationMs, easing: Easing.inOut(Easing.ease), useNativeDriver: false }).start()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Мелочь, про Android. Хост шлёт номинальные 250 мс (MindboxEmbeddedBlockHostView.kt:198). Нативный fade — это ValueAnimator, и система умножает его на множитель анимаций из настроек разработчика. Animated.timing этот множитель не видит. На 0.5x или 2x слот и контент разъезжаются.

Кривая тоже другая. На iOS Easing.inOut(Easing.ease) отличается от easeInOut не больше чем на 3%, там всё в порядке. На Android натив идёт по (0.4, 0, 0.2, 1).

Предлагаю на Android умножать длительность на ValueAnimator.getDurationScale() и брать Easing.bezier(0.4, 0, 0.2, 1). Чище, если натив сам отдаст действующую длительность — тогда Flutter получит это без своих правок.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Кривую поправил в ac4b92a: на Android слот и оверлей идут по Easing.bezier(0.4, 0, 0.2, 1), на iOS остаётся inOut(ease). Множитель длительности здесь не трогаю: ValueAnimator.getDurationScale() публичен только с API 33, ниже нужна рефлексия или Settings.Global, и это дублирование того, что натив уже знает. Согласен с вашим вторым вариантом — пусть MindboxEmbeddedBlockView отдаёт действующую длительность, тогда RN и Flutter получат её одним местом. Заведу задачей в android-sdk, здесь номинальные 250 мс остаются до неё.

Comment thread README.md Outdated
Comment thread src/__tests__/MindboxEmbeddedBlock.test.tsx
Comment thread android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt Outdated
Comment thread src/MindboxEmbeddedBlockFailReason.ts Outdated
Comment thread src/MindboxEmbeddedBlock.tsx Outdated
Vailence added 4 commits October 6, 2026 02:35
…by, on the native curve

The native block fades its content in from transparent, and the stand-in the
host's placeholder or error screen is drawn over is clear. The overlay went
away in the frame the look changed, so for the length of the fade the screen's
background showed through where the placeholder had been. SwiftUI writes the
look inside `withAnimation` and Compose fades its stand-in under the content;
here the overlay lies above the native view, so it stays and fades out over
the same duration, touches passing through to the content meanwhile. Any
other change takes it away at once, a fade interrupted included.

The slot's growth and this fade now follow the native curve per platform:
Material's standard curve on Android, ease-in-out on iOS. Tests cover the
fade, its interruption, the opaque placeholder of a reload after it, and the
cell the old reveal test missed — a placeholder or an error screen arriving
mid-growth takes the full height at once and stops the growth.

A nameless place is also told right: both native blocks report it empty, so
the docs, the warning and the test say `onEmpty`, not `onFail`.
…rror, and where the layout moves

The docs promised an `error` screen and a layout that does not jump; neither
is true for a block that waits hidden, which under the default strategy is
every place on a fresh install. Such a block draws neither `placeholder` nor
`error` until its content has been shown once — a failure keeps it collapsed
and only `onFail` tells — and an `automatic` block at a remembered place takes
its space a frame after the mount, not from the first one. `onLoad` of a block
that waited hidden comes while it is still growing from zero. The targeting
data a `networkError` may stand for is fetched that way on iOS only.
…r the release workflow expects

`mindbox-sdk@2.16.0-rc` is on npm under the `rc` tag with `onFail(failure)`,
the `MindboxEmbeddedBlockFailure` type, a placeholder from the first frame
and a README that hid the stories section in `onFail`. After this release an
empty place goes to `onEmpty`, so that section stays, and the compiler says
nothing about it. The release workflow inserts `## [Unreleased]` with
brackets; the header without them would have left two sections.
… name is known

Fabric sets props in no fixed order: a strategy word arriving before the name
was logged against place 'null'. The word is kept and logged when the block
is built, next to the empty-name log, as the iOS host does.

@justSmK justSmK left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Всё поправлено, спасибо. Тред про длительность на Android закрою, когда появится задача в android-sdk. Мержить после релиза нативов и подъёма пинов.

@Vailence
Vailence merged commit f86685a into mission/stories Oct 6, 2026
5 checks passed
@Vailence
Vailence deleted the feature/MOBILE-557 branch October 6, 2026 11:52
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