From 31f8f5add1cfb8d1bacb6abc6404c2c0f798ed93 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 1 Oct 2026 21:56:42 +0500 Subject: [PATCH 01/10] MOBILE-557: Split the embedded block outcome into onLoad, onEmpty and onFail(reason) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 + README.md | 19 ++- .../embedded/MindboxEmbeddedBlockEvents.kt | 26 ++-- .../embedded/MindboxEmbeddedBlockHostView.kt | 15 +- .../MindboxEmbeddedBlockViewManager.kt | 10 +- .../MindboxEmbeddedBlockHost.swift | 16 +- ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm | 45 +++--- src/MindboxEmbeddedBlock.tsx | 134 +++++++++++----- src/MindboxEmbeddedBlockFailReason.ts | 30 ++++ src/MindboxEmbeddedBlockNativeComponent.ts | 13 +- src/__tests__/MindboxEmbeddedBlock.test.tsx | 143 +++++++++++++++--- .../MindboxEmbeddedBlock.unsupported.test.tsx | 54 +++++++ src/index.tsx | 4 +- 13 files changed, 401 insertions(+), 114 deletions(-) create mode 100644 src/MindboxEmbeddedBlockFailReason.ts create mode 100644 src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 62748b8..3c0e41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Changes +- Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel, a native component for both the old and the new architecture. +- `MindboxEmbeddedBlock` reports three outcomes, as the native SwiftUI and Compose blocks do: `onLoad`, `onEmpty` and `onFail` with a `MindboxEmbeddedBlockFailReason` (`networkError` or `internalError`). + ## [2.15.3] - 2026-08-26 ### Changes diff --git a/README.md b/README.md index 4f101aa..0ab2ae9 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,18 @@ import { MindboxEmbeddedBlock } from 'mindbox-sdk'; ``` -Both outcomes can be customized, the same way as in SwiftUI, Compose and Flutter: `placeholder` +The outcome arrives through three callbacks, the same three as in SwiftUI, Compose and Flutter: +`onLoad` when the content is shown, `onEmpty` when there is nothing to show at the place, and +`onFail` with a `MindboxEmbeddedBlockFailReason` when the block could not be shown. An empty place +is a normal outcome, not a breakage, and comes with no reason. A failure's reason — `networkError` +or `internalError` — is for logs and analytics, not for branching: by the time it arrives the block +has already collapsed or switched to `error`. A later SDK may add reasons, so keep a fallback when +matching. + +Both looks can be customized, the same way as in SwiftUI, Compose and Flutter: `placeholder` replaces the stock loading shimmer, and `error` opts into showing a failure instead of collapsing. An empty place always collapses — a host cannot fill the space of a block that was never meant to -be there. `onLoad` and `onFail` report how the load ended. +be there. ```tsx } error={} - onFail={() => setShowStoriesSection(false)} + onEmpty={() => setShowStoriesSection(false)} + onFail={(reason) => console.log(`stories failed: ${reason}`)} /> ``` @@ -83,6 +92,10 @@ reload. It has to be positive, though: a block given no space to occupy is never no outcome. `timeoutMs` is fixed when the block is created — a new value is ignored with a warning; give the component a new `key` to load a block on a new budget. +Available on iOS and Android. On any other platform the block collapses right away and reports +`onFail` with `internalError`, so a layout that hides its section on failure behaves the same +everywhere. + ### Push Notifications Mindbox SDK aids in handling push notifications. It offers configurations and usage instructions, found in the SDK documentation [Android(FCM)](https://developers.mindbox.ru/docs/firebase-send-push-notifications-react-native), [Android(HCM)](https://developers.mindbox.ru/docs/huawei-send-push-notifications-react-native), [IOS](https://developers.mindbox.ru/docs/ios-send-push-notifications-react-native) and [IOS(Rich)](https://developers.mindbox.ru/docs/ios-send-rich-push-react-native). diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt index a4a04e0..e63fc2f 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt @@ -20,22 +20,24 @@ internal class AppearanceChangeEvent( } } -internal class BlockLoadEvent(surfaceId: Int, viewTag: Int) : Event(surfaceId, viewTag) { +/** + * How the load ended: `load`, `empty` or `fail`, the reason going with a failure. An event payload + * has every field, so a missing reason is spelled as an empty string. + */ +internal class BlockOutcomeEvent( + surfaceId: Int, + viewTag: Int, + private val outcome: String, + private val reason: String?, +) : Event(surfaceId, viewTag) { override fun getEventName(): String = EVENT_NAME - override fun getEventData(): WritableMap = Arguments.createMap() - - companion object { - const val EVENT_NAME = "topBlockLoad" + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putString("outcome", outcome) + putString("reason", reason.orEmpty()) } -} - -internal class BlockFailEvent(surfaceId: Int, viewTag: Int) : Event(surfaceId, viewTag) { - override fun getEventName(): String = EVENT_NAME - - override fun getEventData(): WritableMap = Arguments.createMap() companion object { - const val EVENT_NAME = "topBlockFail" + const val EVENT_NAME = "topBlockOutcome" } } diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt index e0a5d0b..68456c2 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.setViewTreeLifecycleOwner import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockFailReason import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView import cloud.mindbox.mobile_sdk.logger.Level @@ -19,7 +20,8 @@ import cloud.mindbox.mobile_sdk.logger.Level internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(context) { var onAppearance: ((String) -> Unit)? = null - var onOutcome: ((String) -> Unit)? = null + /** The outcome word and, for a failure, the reason's raw value. */ + var onOutcome: ((outcome: String, reason: String?) -> Unit)? = null private var blockView: MindboxEmbeddedBlockView? = null private var placeSystemName: String? = null @@ -141,11 +143,15 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont block.setListener( object : MindboxEmbeddedBlockListener { override fun onLoad(view: MindboxEmbeddedBlockView) { - onOutcome?.invoke(OUTCOME_LOAD) + onOutcome?.invoke(OUTCOME_LOAD, null) } - override fun onFail(view: MindboxEmbeddedBlockView) { - onOutcome?.invoke(OUTCOME_FAIL) + override fun onEmpty(view: MindboxEmbeddedBlockView) { + onOutcome?.invoke(OUTCOME_EMPTY, null) + } + + override fun onFail(view: MindboxEmbeddedBlockView, reason: MindboxEmbeddedBlockFailReason) { + onOutcome?.invoke(OUTCOME_FAIL, reason.value) } }, ) @@ -205,6 +211,7 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont private companion object { const val OUTCOME_LOAD = "load" + const val OUTCOME_EMPTY = "empty" const val OUTCOME_FAIL = "fail" fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) { diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt index c205157..bb2ffc2 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt @@ -28,10 +28,8 @@ internal class MindboxEmbeddedBlockViewManager : view.onAppearance = { appearance -> dispatch(view) { surfaceId, tag -> AppearanceChangeEvent(surfaceId, tag, appearance) } } - view.onOutcome = { outcome -> - dispatch(view) { surfaceId, tag -> - if (outcome == OUTCOME_LOAD) BlockLoadEvent(surfaceId, tag) else BlockFailEvent(surfaceId, tag) - } + view.onOutcome = { outcome, reason -> + dispatch(view) { surfaceId, tag -> BlockOutcomeEvent(surfaceId, tag, outcome, reason) } } } @@ -74,8 +72,7 @@ internal class MindboxEmbeddedBlockViewManager : override fun getExportedCustomDirectEventTypeConstants(): MutableMap = mutableMapOf( AppearanceChangeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onAppearanceChange"), - BlockLoadEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockLoad"), - BlockFailEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockFail"), + BlockOutcomeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockOutcome"), ) private inline fun dispatch( @@ -89,6 +86,5 @@ internal class MindboxEmbeddedBlockViewManager : internal companion object { const val NAME = "MindboxEmbeddedBlockView" - const val OUTCOME_LOAD = "load" } } diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift index 5161f54..9878434 100644 --- a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift +++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift @@ -6,7 +6,8 @@ import MindboxLogger public final class MindboxEmbeddedBlockHost: NSObject { @objc public var onAppearance: ((NSString) -> Void)? - @objc public var onOutcome: ((NSString) -> Void)? + /// The outcome word and, for a failure, the reason's raw value. + @objc public var onOutcome: ((NSString, NSString?) -> Void)? @objc public var view: UIView { blockView } @@ -80,12 +81,19 @@ public final class MindboxEmbeddedBlockHost: NSObject { } } +// All three methods are implemented on purpose: the protocol gives each an empty default, so a +// host that still spelled the old `DidFail(_:)` would compile and silently hear no failure. extension MindboxEmbeddedBlockHost: MindboxEmbeddedBlockViewDelegate { public func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { - onOutcome?("load") + onOutcome?("load", nil) } - public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { - onOutcome?("fail") + public func mindboxEmbeddedBlockViewDidBecomeEmpty(_ blockView: MindboxEmbeddedBlockView) { + onOutcome?("empty", nil) + } + + public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView, + reason: MindboxEmbeddedBlockFailReason) { + onOutcome?("fail", reason.rawValue as NSString) } } diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm index 4340088..78e25f1 100644 --- a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm +++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm @@ -1,7 +1,7 @@ // The embedded block on iOS, in both renderers. // // The block itself lives in `MindboxEmbeddedBlockHost` — plain UIKit, no React in it — and each -// renderer only carries props to it and its two signals back. Fabric does that through a component +// renderer only carries props to it and its two signals back — the look and the outcome. Fabric does that through a component // view and a C++ event emitter; the old renderer through a view manager and direct event blocks. // The order of the calls into the host is kept the same in both, so a block behaves the same way // whichever renderer the host app runs. @@ -14,9 +14,6 @@ #error "MindboxSdk-Swift.h not found. Ensure Swift sources are included in the MindboxSdk pod target." #endif -// The outcome words the Swift host reports. A contract with the host, not derived from anything. -static NSString *const MindboxEmbeddedBlockOutcomeLoad = @"load"; - #ifdef RCT_NEW_ARCH_ENABLED #import @@ -43,6 +40,7 @@ @implementation MindboxEmbeddedBlockViewComponentView { NSString *_pendingAppearance; NSString *_pendingOutcome; + NSString *_pendingOutcomeReason; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -112,8 +110,8 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask _host.onAppearance = ^(NSString *appearance) { [weakSelf emitAppearance:appearance]; }; - _host.onOutcome = ^(NSString *outcome) { - [weakSelf emitOutcome:outcome]; + _host.onOutcome = ^(NSString *outcome, NSString *_Nullable reason) { + [weakSelf emitOutcome:outcome reason:reason]; }; self.contentView = _host.view; @@ -131,8 +129,10 @@ - (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter if (_pendingOutcome != nil) { NSString *outcome = _pendingOutcome; + NSString *reason = _pendingOutcomeReason; _pendingOutcome = nil; - [self emitOutcome:outcome]; + _pendingOutcomeReason = nil; + [self emitOutcome:outcome reason:reason]; } } @@ -153,19 +153,18 @@ - (void)emitAppearance:(NSString *)appearance ->onAppearanceChange({.appearance = std::string([appearance UTF8String])}); } -- (void)emitOutcome:(NSString *)outcome +- (void)emitOutcome:(NSString *)outcome reason:(NSString *_Nullable)reason { if (!_eventEmitter) { _pendingOutcome = outcome; + _pendingOutcomeReason = reason; return; } - const auto emitter = std::static_pointer_cast(_eventEmitter); - if ([outcome isEqualToString:MindboxEmbeddedBlockOutcomeLoad]) { - emitter->onBlockLoad({}); - } else { - emitter->onBlockFail({}); - } + // The payload has every field: a reason that is not there is an empty string. + std::static_pointer_cast(_eventEmitter) + ->onBlockOutcome({.outcome = std::string([outcome UTF8String]), + .reason = std::string([(reason ?: @"") UTF8String])}); } - (void)dropHost @@ -179,6 +178,7 @@ - (void)dropHost _host = nil; _pendingAppearance = nil; _pendingOutcome = nil; + _pendingOutcomeReason = nil; } @end @@ -215,8 +215,7 @@ @interface MindboxEmbeddedBlockPaperView : RCTView @property (nonatomic, assign) BOOL hostVisible; @property (nonatomic, copy) RCTDirectEventBlock onAppearanceChange; -@property (nonatomic, copy) RCTDirectEventBlock onBlockLoad; -@property (nonatomic, copy) RCTDirectEventBlock onBlockFail; +@property (nonatomic, copy) RCTDirectEventBlock onBlockOutcome; @end @@ -291,14 +290,11 @@ - (void)buildHostIfPossible strongSelf.onAppearanceChange(@{@"appearance" : appearance}); } }; - _host.onOutcome = ^(NSString *outcome) { + _host.onOutcome = ^(NSString *outcome, NSString *_Nullable reason) { MindboxEmbeddedBlockPaperView *strongSelf = weakSelf; - if ([outcome isEqualToString:MindboxEmbeddedBlockOutcomeLoad]) { - if (strongSelf.onBlockLoad != nil) { - strongSelf.onBlockLoad(@{}); - } - } else if (strongSelf.onBlockFail != nil) { - strongSelf.onBlockFail(@{}); + if (strongSelf.onBlockOutcome != nil) { + // The same shape as under Fabric: every field present, a missing reason is empty. + strongSelf.onBlockOutcome(@{@"outcome" : outcome, @"reason" : reason ?: @""}); } }; @@ -346,8 +342,7 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(hostVisible, BOOL) RCT_EXPORT_VIEW_PROPERTY(onAppearanceChange, RCTDirectEventBlock) -RCT_EXPORT_VIEW_PROPERTY(onBlockLoad, RCTDirectEventBlock) -RCT_EXPORT_VIEW_PROPERTY(onBlockFail, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onBlockOutcome, RCTDirectEventBlock) @end diff --git a/src/MindboxEmbeddedBlock.tsx b/src/MindboxEmbeddedBlock.tsx index a69f955..3297406 100644 --- a/src/MindboxEmbeddedBlock.tsx +++ b/src/MindboxEmbeddedBlock.tsx @@ -1,14 +1,16 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { StyleSheet, View } from 'react-native' +import { Platform, StyleSheet, View } from 'react-native' import type { StyleProp, ViewStyle } from 'react-native' import MindboxEmbeddedBlockNativeView from './MindboxEmbeddedBlockNativeComponent' import type { NativeProps } from './MindboxEmbeddedBlockNativeComponent' +import { MindboxEmbeddedBlockFailReason } from './MindboxEmbeddedBlockFailReason' /** - * The handler is typed by the prop it is handed to, not by a second spelling of the same event. + * The handlers are typed by the props they are handed to, not by a second spelling of the same + * events. * - * Spelling it out again would mean naming `NativeSyntheticEvent` here as well, and the two names + * Spelling them out again would mean naming `NativeSyntheticEvent` here as well, and the two names * only agree while both resolve to the same React Native. They do not always: a checkout that sits * under a folder carrying its own React Native resolves this file's import and the spec's to * different copies, and `bob build` then refuses to write the definitions over a `currentTarget` @@ -16,19 +18,19 @@ import type { NativeProps } from './MindboxEmbeddedBlockNativeComponent' * nothing to disagree about. */ type AppearanceChangeHandler = NonNullable +type BlockOutcomeHandler = NonNullable type Appearance = 'placeholder' | 'content' | 'error' | 'collapsed' const APPEARANCES: Array = ['placeholder', 'content', 'error', 'collapsed'] -/** - * Why the place ended up without content. - * - * Empty today, and an object rather than nothing on purpose: the SDK does not yet tell a timeout from - * an empty place from a network failure, and when it does the reason lands here without breaking a - * single caller. - */ -export type MindboxEmbeddedBlockFailure = {} +/** How the load ended — the three words the native blocks report, the same on both platforms. */ +type Outcome = 'load' | 'empty' | 'fail' + +const OUTCOMES: Array = ['load', 'empty', 'fail'] + +/** iOS and Android have the native block; nothing else does. */ +const IS_SUPPORTED = Platform.OS === 'ios' || Platform.OS === 'android' export type MindboxEmbeddedBlockProps = { /** @@ -82,14 +84,39 @@ export type MindboxEmbeddedBlockProps = { */ error?: React.ReactNode - /** The content is shown. */ + /** + * The content is shown: the block has taken its height and is visible. + * + * Delivered once per outcome, not once per lifetime: the same outcome is never repeated, and an + * outcome that actually changed — a place that filled up after a failure — is delivered again. + * The native block reports the same way, so every wrapper of the SDK calls back alike. + */ onLoad?: () => void /** - * The place ended up without content: the load failed or timed out, or there is nothing behind the - * name. An empty place is a normal outcome, not a breakage. + * There is nothing to show at the place: no campaign behind its name, the targeting or the A/B + * group did not match, the show budget is spent, or the page rendered nothing. A normal outcome, + * not a breakage: the block collapses, [error] does not apply, and no reason is given — which of + * these it was is the SDK's business. + * + * Delivered on the same rule as [onLoad]: once per outcome, again if the outcome changes. */ - onFail?: (failure: MindboxEmbeddedBlockFailure) => void + onEmpty?: () => void + + /** + * The block could not be shown: the SDK had no config or never answered, the page could not be + * loaded, the content is malformed or the SDK hit an internal error. The block collapses, or keeps + * its height and draws [error] when one is given. An empty place is not a failure and arrives in + * [onEmpty] instead. + * + * The reason is for logs and analytics, not for branching: whatever it is, the block has already + * collapsed or switched to [error]. Compare it with the constants of + * [MindboxEmbeddedBlockFailReason] and keep a fallback — a later SDK may add reasons. + * + * Delivered on the same rule as [onLoad]: once per outcome, again if the outcome changes. A + * failure that repeats with a different reason is the same outcome and is not delivered again. + */ + onFail?: (reason: MindboxEmbeddedBlockFailReason) => void /** * Whether the screen the block stands on is the one being looked at. `true` by default. @@ -115,20 +142,27 @@ export type MindboxEmbeddedBlockProps = { * * ``` * - * Both outcomes can be customized, the same way as in SwiftUI, Compose and Flutter: [placeholder] + * Both looks can be customized, the same way as in SwiftUI, Compose and Flutter: [placeholder] * replaces the stock loading shimmer, and [error] opts into showing a failure instead of collapsing. * Both stay ordinary RN nodes, drawn above the native view, so they resolve the context, the theme * and the handlers of the tree the block itself stands in. The wait for an answer is bounded by * [timeoutMs] — 30 seconds unless the host says otherwise. * + * The outcome arrives through three callbacks, the same three as in SwiftUI, Compose and Flutter: + * [onLoad] when the content is shown, [onEmpty] when there is nothing to show at the place, and + * [onFail] with a reason when the block could not be shown. + * * The component is a thin layer over the native block: the native view holds the SDK's own container * — with its waiting budget and its web page — and this component only mirrors the container's * decisions in the RN layout. + * + * **iOS and Android.** On any other platform the block collapses right away and reports [onFail] + * with `internalError`, so a layout that hides its section on failure behaves the same everywhere. */ export const MindboxEmbeddedBlock = (props: MindboxEmbeddedBlockProps) => -const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, onFail, active = true, style }: MindboxEmbeddedBlockProps) => { - const [appearance, setAppearance] = useState('placeholder') +const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, onEmpty, onFail, active = true, style }: MindboxEmbeddedBlockProps) => { + const [appearance, setAppearance] = useState(IS_SUPPORTED ? 'placeholder' : 'collapsed') const blockHeight = Number.isFinite(height) ? Math.max(0, height) : 0 @@ -159,7 +193,43 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" keeps the timeout it was created with; the new value is ignored. Remount the component — give it a new key — to change the timeout.`) }, [timeoutMs, creationTimeoutMs, placeSystemName]) - const deliveredOutcome = useRef<'load' | 'fail' | null>(null) + // The callbacks are read through a ref at delivery time, so a host that passes a fresh closure + // on every render neither re-subscribes anything nor misses the one delivery of an outcome. + const callbacks = useRef({ onLoad, onEmpty, onFail }) + callbacks.current = { onLoad, onEmpty, onFail } + + /** + * The outcome delivered last — the deduplication key. Deliberately without the failure reason: + * a silent retry that fails differently is still the same outcome. + */ + const deliveredOutcome = useRef(null) + + const deliver = useCallback((outcome: Outcome, reason: string | null) => { + if (outcome === deliveredOutcome.current) { + return + } + deliveredOutcome.current = outcome + switch (outcome) { + case 'load': + callbacks.current.onLoad?.() + break + case 'empty': + callbacks.current.onEmpty?.() + break + case 'fail': + callbacks.current.onFail?.(reason || MindboxEmbeddedBlockFailReason.internalError) + break + } + }, []) + + // No native block to report: the place collapses and the host hears the same failure it would + // hear from a block that could not be shown. In an effect, after the first commit, as a native + // block's report would arrive after the first layout. + useEffect(() => { + if (!IS_SUPPORTED) { + deliver('fail', MindboxEmbeddedBlockFailReason.internalError) + } + }, [deliver]) const handleAppearanceChange = useCallback((event) => { const reported = event.nativeEvent.appearance @@ -168,21 +238,15 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, } }, []) - const handleLoad = useCallback(() => { - if (deliveredOutcome.current === 'load') { - return - } - deliveredOutcome.current = 'load' - onLoad?.() - }, [onLoad]) - - const handleFail = useCallback(() => { - if (deliveredOutcome.current === 'fail') { - return - } - deliveredOutcome.current = 'fail' - onFail?.({}) - }, [onFail]) + const handleOutcome = useCallback( + (event) => { + const { outcome, reason } = event.nativeEvent + if (OUTCOMES.includes(outcome)) { + deliver(outcome as Outcome, reason) + } + }, + [deliver] + ) const overlay = appearance === 'placeholder' ? placeholder : appearance === 'error' ? error : null @@ -194,7 +258,7 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, style={[styles.block, style, { height: appearance === 'collapsed' ? 0 : blockHeight }]} collapsable={false} > - + {IS_SUPPORTED ? : null} {overlay != null ? ( {overlay} diff --git a/src/MindboxEmbeddedBlockFailReason.ts b/src/MindboxEmbeddedBlockFailReason.ts new file mode 100644 index 0000000..31dcce3 --- /dev/null +++ b/src/MindboxEmbeddedBlockFailReason.ts @@ -0,0 +1,30 @@ +/** + * Why a block could not be shown — the payload of `MindboxEmbeddedBlock`'s `onFail`. + * + * Meant for logs and analytics on the host side, not for branching: whatever the reason, the block + * has already collapsed or switched to its error screen. A string rather than a closed union so a + * later SDK can add a reason without breaking an exhaustive `switch` — keep a fallback when + * matching. The raw values are the same on every platform: what the native iOS and Android blocks + * report under these names reaches React Native unchanged. + * + * The constants below are the ones this version knows; a later SDK may report a word that is not + * among them, and it arrives as it is. + */ +export type MindboxEmbeddedBlockFailReason = 'networkError' | 'internalError' | (string & {}) + +export const MindboxEmbeddedBlockFailReason = { + /** + * The content is unavailable because of the environment: the config could not be downloaded and + * nothing is cached, the SDK gave no answer within the block's waiting budget, the block's page + * could not be loaded, or the data the targeting needs could not be fetched — typically a + * network problem. + */ + networkError: 'networkError', + + /** + * An error on the Mindbox side: the page loaded but never reported its content or reported + * something unusable, or the SDK failed inside. Also what a platform without a native block + * reports. + */ + internalError: 'internalError', +} as const diff --git a/src/MindboxEmbeddedBlockNativeComponent.ts b/src/MindboxEmbeddedBlockNativeComponent.ts index 35a1c68..895c71b 100644 --- a/src/MindboxEmbeddedBlockNativeComponent.ts +++ b/src/MindboxEmbeddedBlockNativeComponent.ts @@ -6,6 +6,15 @@ type AppearanceChangeEvent = Readonly<{ appearance: string }> +/** + * How the load ended: `load`, `empty` or `fail`. The reason goes with a failure and is empty + * otherwise — an event payload has every field, so the absence is spelled as an empty string. + */ +type BlockOutcomeEvent = Readonly<{ + outcome: string + reason: string +}> + export interface NativeProps extends ViewProps { placeSystemName?: string @@ -21,9 +30,7 @@ export interface NativeProps extends ViewProps { onAppearanceChange?: DirectEventHandler - onBlockLoad?: DirectEventHandler - - onBlockFail?: DirectEventHandler + onBlockOutcome?: DirectEventHandler } export default codegenNativeComponent('MindboxEmbeddedBlockView') diff --git a/src/__tests__/MindboxEmbeddedBlock.test.tsx b/src/__tests__/MindboxEmbeddedBlock.test.tsx index c89101e..0b26b62 100644 --- a/src/__tests__/MindboxEmbeddedBlock.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx @@ -4,6 +4,7 @@ import { act, create } from 'react-test-renderer' import type { ReactTestRenderer } from 'react-test-renderer' import { MindboxEmbeddedBlock } from '../MindboxEmbeddedBlock' +import { MindboxEmbeddedBlockFailReason } from '../MindboxEmbeddedBlockFailReason' jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { const ReactActual = require('react') @@ -38,6 +39,13 @@ const reportAppearance = (renderer: ReactTestRenderer, appearance: string) => { }) } +/** The native block's report of how the load ended; an empty reason is what the event carries when there is none. */ +const reportOutcome = (renderer: ReactTestRenderer, outcome: string, reason = '') => { + act(() => { + nativeProps(renderer).onBlockOutcome({ nativeEvent: { outcome, reason } }) + }) +} + const frameHeight = (renderer: ReactTestRenderer) => { const frame = renderer.root.findAllByType(asType(View))[0] return StyleSheet.flatten(frame.props.style).height @@ -101,11 +109,10 @@ describe('MindboxEmbeddedBlock', () => { const renderer = render() reportAppearance(renderer, 'collapsed') - act(() => { - nativeProps(renderer).onBlockFail() - }) + reportOutcome(renderer, 'fail', 'internalError') expect(onFail).toHaveBeenCalledTimes(1) + expect(onFail).toHaveBeenCalledWith('internalError') expect(frameHeight(renderer)).toBe(0) warn.mockRestore() }) @@ -175,23 +182,110 @@ describe('MindboxEmbeddedBlock', () => { expect(renderer.root.findByType(asType(Text)).props.children).toBe('broken') }) - it('delivers each outcome once and a changed outcome again', () => { - const onLoad = jest.fn() - const onFail = jest.fn() - const renderer = render() + describe('the outcome', () => { + it('delivers each outcome once and a changed outcome again', () => { + const onLoad = jest.fn() + const onFail = jest.fn() + const renderer = render() + + reportOutcome(renderer, 'load') + reportOutcome(renderer, 'load') + + expect(onLoad).toHaveBeenCalledTimes(1) - act(() => { - nativeProps(renderer).onBlockLoad() - nativeProps(renderer).onBlockLoad() + reportOutcome(renderer, 'fail', 'networkError') + + expect(onFail).toHaveBeenCalledTimes(1) }) - expect(onLoad).toHaveBeenCalledTimes(1) + it('reports an empty place through onEmpty, with the space given back and no reason', () => { + const onEmpty = jest.fn() + const onFail = jest.fn() + const renderer = render() + + reportAppearance(renderer, 'collapsed') + reportOutcome(renderer, 'empty') - act(() => { - nativeProps(renderer).onBlockFail() + expect(onEmpty).toHaveBeenCalledTimes(1) + expect(onEmpty).toHaveBeenCalledWith() + expect(onFail).not.toHaveBeenCalled() + expect(frameHeight(renderer)).toBe(0) }) - expect(onFail).toHaveBeenCalledTimes(1) + it('hands the failure reason to onFail as the native block spelled it', () => { + const onFail = jest.fn() + const renderer = render() + + reportOutcome(renderer, 'fail', 'networkError') + + expect(onFail).toHaveBeenCalledWith(MindboxEmbeddedBlockFailReason.networkError) + }) + + it('passes a reason it does not know through as it is', () => { + const onFail = jest.fn() + const renderer = render() + + reportOutcome(renderer, 'fail', 'sideways') + + expect(onFail).toHaveBeenCalledWith('sideways') + }) + + it('calls a failure without a reason an internal error', () => { + const onFail = jest.fn() + const renderer = render() + + reportOutcome(renderer, 'fail') + + expect(onFail).toHaveBeenCalledWith(MindboxEmbeddedBlockFailReason.internalError) + }) + + it('delivers a failure that repeats with another reason once', () => { + const onFail = jest.fn() + const renderer = render() + + reportOutcome(renderer, 'fail', 'networkError') + reportOutcome(renderer, 'fail', 'internalError') + + expect(onFail).toHaveBeenCalledTimes(1) + expect(onFail).toHaveBeenCalledWith('networkError') + }) + + it('delivers a sequence of outcomes in order, repeats dropped', () => { + const delivered: Array = [] + const renderer = render( delivered.push('load')} onEmpty={() => delivered.push('empty')} onFail={(reason) => delivered.push(`fail:${reason}`)} />) + + reportOutcome(renderer, 'empty') + reportOutcome(renderer, 'empty') + reportOutcome(renderer, 'load') + reportOutcome(renderer, 'fail', 'networkError') + + expect(delivered).toEqual(['empty', 'load', 'fail:networkError']) + }) + + it('ignores an outcome it does not know', () => { + const onLoad = jest.fn() + const onEmpty = jest.fn() + const onFail = jest.fn() + const renderer = render() + + reportOutcome(renderer, 'sideways') + + expect(onLoad).not.toHaveBeenCalled() + expect(onEmpty).not.toHaveBeenCalled() + expect(onFail).not.toHaveBeenCalled() + }) + + it('calls the handler the host passed last, not the one it passed first', () => { + const first = jest.fn() + const second = jest.fn() + const renderer = render() + + update(renderer, ) + reportOutcome(renderer, 'load') + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) }) it('ignores an appearance it does not know, keeping the last known one', () => { @@ -207,14 +301,23 @@ describe('MindboxEmbeddedBlock', () => { const onLoad = jest.fn() const renderer = render() - act(() => { - nativeProps(renderer).onBlockLoad() - }) + reportOutcome(renderer, 'load') update(renderer, ) - act(() => { - nativeProps(renderer).onBlockLoad() - }) + reportOutcome(renderer, 'load') expect(onLoad).toHaveBeenCalledTimes(2) }) }) + +describe('MindboxEmbeddedBlockFailReason', () => { + it('names the reasons by the raw values the native blocks report', () => { + expect(MindboxEmbeddedBlockFailReason.networkError).toBe('networkError') + expect(MindboxEmbeddedBlockFailReason.internalError).toBe('internalError') + }) + + it('is a string, so a word a later SDK adds is still a reason', () => { + const reason: MindboxEmbeddedBlockFailReason = 'quotaExceeded' + + expect(reason).toBe('quotaExceeded') + }) +}) diff --git a/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx b/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx new file mode 100644 index 0000000..6f9a2ae --- /dev/null +++ b/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { StyleSheet, View } from 'react-native' +import { act, create } from 'react-test-renderer' +import type { ReactTestRenderer } from 'react-test-renderer' + +/** + * A platform without the native block — anything but iOS and Android. `Platform.OS` is read when + * the component module loads, so the platform is set before the component is required, in a test + * file of its own. + */ +jest.mock('react-native/Libraries/Utilities/Platform', () => ({ + OS: 'web', + select: (specifics: Record) => specifics.default, +})) + +jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { + const ReactActual = require('react') + const { View: RNView } = require('react-native') + return { + __esModule: true, + default: (props: unknown) => ReactActual.createElement(RNView, { ...(props as object), testID: 'native-block' }), + } +}) + +const { MindboxEmbeddedBlock } = require('../MindboxEmbeddedBlock') + +const render = (element: React.ReactElement): ReactTestRenderer => { + let renderer: ReactTestRenderer + act(() => { + renderer = create(element as any) + }) + return renderer! +} + +const frameHeight = (renderer: ReactTestRenderer) => { + const frame = renderer.root.findAllByType(View as any)[0] + return StyleSheet.flatten(frame.props.style).height +} + +describe('MindboxEmbeddedBlock on a platform without the native block', () => { + it('takes no space, builds no native view and reports an internal error', () => { + const onLoad = jest.fn() + const onEmpty = jest.fn() + const onFail = jest.fn() + const renderer = render() + + expect(frameHeight(renderer)).toBe(0) + expect(renderer.root.findAllByProps({ testID: 'native-block' })).toHaveLength(0) + expect(onFail).toHaveBeenCalledTimes(1) + expect(onFail).toHaveBeenCalledWith('internalError') + expect(onLoad).not.toHaveBeenCalled() + expect(onEmpty).not.toHaveBeenCalled() + }) +}) diff --git a/src/index.tsx b/src/index.tsx index 68868bc..fc74cb5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -496,4 +496,6 @@ export { InAppCallback, CopyPayloadInAppCallback, EmptyInAppCallback, UrlInAppCa export { MindboxEmbeddedBlock } from './MindboxEmbeddedBlock' -export type { MindboxEmbeddedBlockProps, MindboxEmbeddedBlockFailure } from './MindboxEmbeddedBlock' +export type { MindboxEmbeddedBlockProps } from './MindboxEmbeddedBlock' + +export { MindboxEmbeddedBlockFailReason } from './MindboxEmbeddedBlockFailReason' From e8cdeffa5aaf64f103bf5ebae7f30abec6a0a014 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 1 Oct 2026 22:04:02 +0500 Subject: [PATCH 02/10] MOBILE-557: Let the block start hidden, with a placeholder, or by the place's memory, and reveal with the SDK's animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + README.md | 23 +- .../java/com/mindboxsdk/MindboxSdkModule.kt | 34 ++ .../mindboxsdk/embedded/EmbeddedBlockWire.kt | 31 ++ .../embedded/MindboxEmbeddedBlockEvents.kt | 9 + .../embedded/MindboxEmbeddedBlockHostView.kt | 69 +++-- .../MindboxEmbeddedBlockViewManager.kt | 12 +- ios/EmbeddedBlock/EmbeddedBlockWire.swift | 33 ++ .../MindboxEmbeddedBlockHost.swift | 50 ++- ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm | 44 ++- ios/MindboxSdk.m | 2 + ios/MindboxSdk.swift | 25 +- src/MindboxEmbeddedBlock.tsx | 177 +++++++++-- src/MindboxEmbeddedBlockLoadingStrategy.ts | 39 +++ src/MindboxEmbeddedBlockNativeComponent.ts | 15 +- src/MindboxEmbeddedBlockNativeModule.ts | 20 ++ src/__tests__/MindboxEmbeddedBlock.test.tsx | 291 ++++++++++++++++-- .../MindboxEmbeddedBlock.unsupported.test.tsx | 2 + src/index.tsx | 2 + 19 files changed, 771 insertions(+), 108 deletions(-) create mode 100644 android/src/main/java/com/mindboxsdk/embedded/EmbeddedBlockWire.kt create mode 100644 ios/EmbeddedBlock/EmbeddedBlockWire.swift create mode 100644 src/MindboxEmbeddedBlockLoadingStrategy.ts create mode 100644 src/MindboxEmbeddedBlockNativeModule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0e41d..8c09182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel, a native component for both the old and the new architecture. - `MindboxEmbeddedBlock` reports three outcomes, as the native SwiftUI and Compose blocks do: `onLoad`, `onEmpty` and `onFail` with a `MindboxEmbeddedBlockFailReason` (`networkError` or `internalError`). +- `MindboxEmbeddedBlock` takes a `loadingStrategy` — `automatic` (the default: hidden until the place has shown content once on this device, a placeholder from then on), `placeholder` or `hidden` — and `animatesReveal`, as the native SwiftUI and Compose blocks do. A block that waited hidden grows to its height with the SDK's reveal when its content arrives. ## [2.15.3] - 2026-08-26 diff --git a/README.md b/README.md index 0ab2ae9..b1047b7 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,29 @@ a screen nobody is looking at. /> ``` +What the block shows until the SDK has decided what goes into it is `loadingStrategy`, the same +three choices as in SwiftUI, Compose and Flutter. `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, so +the layout does not jump where content is expected and does not flash where it is not. +`placeholder` takes the space up front, worth naming for a place that always has a campaign behind +it. `hidden` never takes it until the content is shown: no placeholder, and no `error` on a failure. +The content is revealed with the SDK's own animation — it fades in, and a block that started hidden +grows to its height — unless `animatesReveal` is off; the system's reduced-motion setting turns it +off as well. Turn it off to animate the block's container yourself in `onLoad`. + +```tsx + +``` + `height` is live: a new value resizes a block already on screen in place — the same content, no reload. It has to be positive, though: a block given no space to occupy is never loaded and reports -no outcome. `timeoutMs` is fixed when the block is created — a new value is ignored with a warning; -give the component a new `key` to load a block on a new budget. +no outcome. `timeoutMs`, `loadingStrategy` and `animatesReveal` are fixed when the block is created +— a new value is ignored with a warning; give the component a new `key` to build a block anew. Available on iOS and Android. On any other platform the block collapses right away and reports `onFail` with `internalError`, so a layout that hides its section on failure behaves the same diff --git a/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt b/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt index 5797f5d..1641a05 100644 --- a/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt +++ b/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt @@ -22,6 +22,9 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableArray import com.facebook.react.modules.core.DeviceEventManagerModule +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView +import com.mindboxsdk.embedded.EmbeddedBlockWire import org.json.JSONObject class MindboxSdkModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -225,6 +228,37 @@ class MindboxSdkModule(private val reactContext: ReactApplicationContext) : Reac } } + /** + * What an embedded block of this place starts with — the look the native block decides before it + * exists, from the SDK's memory of the place. Asked by `MindboxEmbeddedBlock` for an `automatic` + * block, since JS reaches the memory only asynchronously; answered with an appearance word. + * + * Internal to the package: not exported by the JS side. + */ + @OptIn(InternalMindboxApi::class) + @ReactMethod + fun embeddedBlockInitialAppearance(placeSystemName: String, loadingStrategy: String, promise: Promise) { + val strategy = EmbeddedBlockWire.loadingStrategyOf(loadingStrategy) + if (strategy == null) { + promise.reject("bad_arguments", "embeddedBlockInitialAppearance expects a loading strategy word: automatic, placeholder or hidden") + return + } + + // On the main thread, as the native wrappers ask it: the memory is read where the blocks run. + Handler(reactContext.mainLooper).post { + try { + val appearance = MindboxEmbeddedBlockView.initialAppearance( + context = reactContext.applicationContext, + placeSystemName = placeSystemName, + loadingStrategy = strategy, + ) + promise.resolve(EmbeddedBlockWire.nameOf(appearance)) + } catch (error: Throwable) { + promise.reject(error) + } + } + } + @ReactMethod fun pushDelivered(uniqKey: String) { Mindbox.onPushReceived( diff --git a/android/src/main/java/com/mindboxsdk/embedded/EmbeddedBlockWire.kt b/android/src/main/java/com/mindboxsdk/embedded/EmbeddedBlockWire.kt new file mode 100644 index 0000000..954941b --- /dev/null +++ b/android/src/main/java/com/mindboxsdk/embedded/EmbeddedBlockWire.kt @@ -0,0 +1,31 @@ +package com.mindboxsdk.embedded + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockLoadingStrategy + +/** + * The words the block speaks to JS in: the same ones on iOS, the same ones the Flutter plugin + * uses. A contract with the JS side, not derived from anything. + */ +@OptIn(InternalMindboxApi::class) +internal object EmbeddedBlockWire { + const val OUTCOME_LOAD = "load" + const val OUTCOME_EMPTY = "empty" + const val OUTCOME_FAIL = "fail" + + /** `null` for a word this SDK does not know; an empty word is the default, `automatic`. */ + fun loadingStrategyOf(word: String?): MindboxEmbeddedBlockLoadingStrategy? = when (word) { + null, "", "automatic" -> MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC + "placeholder" -> MindboxEmbeddedBlockLoadingStrategy.PLACEHOLDER + "hidden" -> MindboxEmbeddedBlockLoadingStrategy.HIDDEN + else -> null + } + + fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) { + MindboxEmbeddedBlockAppearance.PLACEHOLDER -> "placeholder" + MindboxEmbeddedBlockAppearance.CONTENT -> "content" + MindboxEmbeddedBlockAppearance.ERROR -> "error" + MindboxEmbeddedBlockAppearance.COLLAPSED -> "collapsed" + } +} diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt index e63fc2f..aaa2c47 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt @@ -4,15 +4,24 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.events.Event +/** + * The look the native block shows. `animated` is `true` for the one change that is the SDK's + * reveal of content — the block owns that decision, gates included — and `revealDurationMs` is + * how long it takes; `false` and `0` for every other change. + */ internal class AppearanceChangeEvent( surfaceId: Int, viewTag: Int, private val appearance: String, + private val isRevealAnimated: Boolean, + private val revealDurationMs: Int, ) : Event(surfaceId, viewTag) { override fun getEventName(): String = EVENT_NAME override fun getEventData(): WritableMap = Arguments.createMap().apply { putString("appearance", appearance) + putBoolean("animated", isRevealAnimated) + putInt("revealDurationMs", if (isRevealAnimated) revealDurationMs else 0) } companion object { diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt index 68456c2..fc20a2f 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt @@ -10,15 +10,16 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.setViewTreeLifecycleOwner import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi -import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockFailReason import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockLoadingStrategy import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView import cloud.mindbox.mobile_sdk.logger.Level @OptIn(InternalMindboxApi::class) internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(context) { - var onAppearance: ((String) -> Unit)? = null + /** The appearance word, whether this change is the SDK's animated reveal, and how long it takes. */ + var onAppearance: ((appearance: String, isRevealAnimated: Boolean, revealDurationMs: Int) -> Unit)? = null /** The outcome word and, for a failure, the reason's raw value. */ var onOutcome: ((outcome: String, reason: String?) -> Unit)? = null @@ -26,6 +27,8 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont private var blockView: MindboxEmbeddedBlockView? = null private var placeSystemName: String? = null private var timeoutMs: Long? = null + private var loadingStrategy: MindboxEmbeddedBlockLoadingStrategy = MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC + private var animatesReveal: Boolean = true private var hostVisible: Boolean = true private var hasPlaceholder: Boolean = false private var hasErrorView: Boolean = false @@ -85,6 +88,29 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont } } + // Fixed at creation, as the timeout is: the JS side warns the host about a later value rather + // than applying it, and the native block takes both only through its constructor. + fun setLoadingStrategy(word: String?) { + if (blockView != null) { + return + } + + val strategy = EmbeddedBlockWire.loadingStrategyOf(word) + if (strategy == null) { + Mindbox.writeLog( + message = "[EmbeddedBlock] A React Native block for place '$placeSystemName' was given a loading strategy this SDK does not know ('$word') and starts as automatic", + logLevel = Level.ERROR, + ) + } + loadingStrategy = strategy ?: MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC + } + + fun setAnimatesReveal(animatesReveal: Boolean) { + if (blockView == null) { + this.animatesReveal = animatesReveal + } + } + fun setHostVisible(isHostVisible: Boolean) { if (hostVisible == isHostVisible) { return @@ -135,7 +161,13 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont ) } - val block = MindboxEmbeddedBlockView(context, place, timeoutMs) + val block = MindboxEmbeddedBlockView( + context = context, + placeSystemName = place, + timeoutMs = timeoutMs, + loadingStrategy = loadingStrategy, + animatesReveal = animatesReveal, + ) blockView = block syncStandIns() @@ -143,19 +175,29 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont block.setListener( object : MindboxEmbeddedBlockListener { override fun onLoad(view: MindboxEmbeddedBlockView) { - onOutcome?.invoke(OUTCOME_LOAD, null) + onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_LOAD, null) } override fun onEmpty(view: MindboxEmbeddedBlockView) { - onOutcome?.invoke(OUTCOME_EMPTY, null) + onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_EMPTY, null) } override fun onFail(view: MindboxEmbeddedBlockView, reason: MindboxEmbeddedBlockFailReason) { - onOutcome?.invoke(OUTCOME_FAIL, reason.value) + onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_FAIL, reason.value) } }, ) - block.setAppearanceObserver { appearance -> onAppearance?.invoke(nameOf(appearance)) } + // `isRevealAnimated` is read while the observer runs: the block sets it right before it + // calls, for that one call. The block owns the gates — `animatesReveal`, a window to animate + // in, animations enabled on the device, "only the arrival of content" — and this host only + // passes its word on; the growth of a block that waited hidden is then the JS side's. + block.setAppearanceObserver { appearance -> + onAppearance?.invoke( + EmbeddedBlockWire.nameOf(appearance), + block.isRevealAnimated, + MindboxEmbeddedBlockView.REVEAL_ANIMATION_DURATION_MS.toInt(), + ) + } addView(block, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } @@ -208,17 +250,4 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont isClickable = false isFocusable = false } - - private companion object { - const val OUTCOME_LOAD = "load" - const val OUTCOME_EMPTY = "empty" - const val OUTCOME_FAIL = "fail" - - fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) { - MindboxEmbeddedBlockAppearance.PLACEHOLDER -> "placeholder" - MindboxEmbeddedBlockAppearance.CONTENT -> "content" - MindboxEmbeddedBlockAppearance.ERROR -> "error" - MindboxEmbeddedBlockAppearance.COLLAPSED -> "collapsed" - } - } } diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt index bb2ffc2..45db945 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt @@ -25,8 +25,8 @@ internal class MindboxEmbeddedBlockViewManager : override fun addEventEmitters(reactContext: ThemedReactContext, view: MindboxEmbeddedBlockHostView) { super.addEventEmitters(reactContext, view) - view.onAppearance = { appearance -> - dispatch(view) { surfaceId, tag -> AppearanceChangeEvent(surfaceId, tag, appearance) } + view.onAppearance = { appearance, isRevealAnimated, revealDurationMs -> + dispatch(view) { surfaceId, tag -> AppearanceChangeEvent(surfaceId, tag, appearance, isRevealAnimated, revealDurationMs) } } view.onOutcome = { outcome, reason -> dispatch(view) { surfaceId, tag -> BlockOutcomeEvent(surfaceId, tag, outcome, reason) } @@ -58,6 +58,14 @@ internal class MindboxEmbeddedBlockViewManager : view.setTimeoutMs(value) } + override fun setLoadingStrategy(view: MindboxEmbeddedBlockHostView, value: String?) { + view.setLoadingStrategy(value) + } + + override fun setAnimatesReveal(view: MindboxEmbeddedBlockHostView, value: Boolean) { + view.setAnimatesReveal(value) + } + override fun setHasPlaceholder(view: MindboxEmbeddedBlockHostView, value: Boolean) { view.setHasPlaceholder(value) } diff --git a/ios/EmbeddedBlock/EmbeddedBlockWire.swift b/ios/EmbeddedBlock/EmbeddedBlockWire.swift new file mode 100644 index 0000000..6f4fe58 --- /dev/null +++ b/ios/EmbeddedBlock/EmbeddedBlockWire.swift @@ -0,0 +1,33 @@ +@_spi(Internal) import Mindbox + +/// The words the block speaks to JS in: the same ones on Android, the same ones the Flutter plugin +/// uses. A contract with the JS side, not derived from anything. +enum EmbeddedBlockWire { + static let outcomeLoad = "load" + static let outcomeEmpty = "empty" + static let outcomeFail = "fail" + + /// `nil` for a word this SDK does not know; an empty word is the default, `automatic`. + static func loadingStrategy(of word: String?) -> MindboxEmbeddedBlockLoadingStrategy? { + switch word { + case nil, "", "automatic": return .automatic + case "placeholder": return .placeholder + case "hidden": return .hidden + default: return nil + } + } + + static func name(of appearance: MindboxEmbeddedBlockAppearance) -> String { + switch appearance { + case .placeholder: return "placeholder" + case .content: return "content" + case .error: return "error" + case .collapsed: return "collapsed" + } + } + + /// The SDK's reveal, in whole milliseconds — what JS animates the growth of a hidden block over. + static var revealDurationMs: Int { + Int((MindboxEmbeddedBlockView.revealAnimationDuration * 1000).rounded()) + } +} diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift index 9878434..a3c23bb 100644 --- a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift +++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift @@ -4,7 +4,8 @@ import MindboxLogger @objc(MindboxEmbeddedBlockHost) public final class MindboxEmbeddedBlockHost: NSObject { - @objc public var onAppearance: ((NSString) -> Void)? + /// The appearance word, whether this change is the SDK's animated reveal, and how long it takes. + @objc public var onAppearance: ((NSString, Bool, Int) -> Void)? /// The outcome word and, for a failure, the reason's raw value. @objc public var onOutcome: ((NSString, NSString?) -> Void)? @@ -14,9 +15,21 @@ public final class MindboxEmbeddedBlockHost: NSObject { private let blockView: MindboxEmbeddedBlockView private var isTornDown = false - @objc public init(placeSystemName: String, height: CGFloat, timeoutMs: Double) { + /// `loadingStrategy` is a word — `automatic`, `placeholder` or `hidden`; a word this SDK does not + /// know is logged and read as `automatic`. Both it and `animatesReveal` are fixed here, as the + /// native block takes them only through its initializer. + @objc public init(placeSystemName: String, + height: CGFloat, + timeoutMs: Double, + loadingStrategy: String, + animatesReveal: Bool) { let timeout: TimeInterval? = timeoutMs == 0 ? nil : timeoutMs / 1000 - blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, height: height, timeout: timeout) + let strategy = EmbeddedBlockWire.loadingStrategy(of: loadingStrategy) + blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, + height: height, + loadingStrategy: strategy ?? .automatic, + timeout: timeout, + animatesReveal: animatesReveal) super.init() if placeSystemName.isEmpty { @@ -24,10 +37,22 @@ public final class MindboxEmbeddedBlockHost: NSObject { level: .error, category: .embeddedBlocks) } + if strategy == nil { + Logger.common(message: "[EmbeddedBlock] A React Native block for place '\(placeSystemName)' was given a loading strategy this SDK does not know ('\(loadingStrategy)') and starts as automatic", + level: .error, + category: .embeddedBlocks) + } blockView.delegate = self + // `isRevealAnimated` is read while the observer runs: the block sets it right before it + // calls, for that one call. The block owns the gates — `animatesReveal`, a window to animate + // in, Reduce Motion off, "only the arrival of content" — and this host only passes its word + // on; the growth of a block that waited hidden is then the JS side's. blockView.setAppearanceObserver { [weak self] appearance in - self?.onAppearance?(Self.name(of: appearance) as NSString) + guard let self else { return } + self.onAppearance?(EmbeddedBlockWire.name(of: appearance) as NSString, + self.blockView.isRevealAnimated, + EmbeddedBlockWire.revealDurationMs) } } @@ -70,30 +95,21 @@ public final class MindboxEmbeddedBlockHost: NSObject { standIn.isUserInteractionEnabled = false return standIn } - - private static func name(of appearance: MindboxEmbeddedBlockAppearance) -> String { - switch appearance { - case .placeholder: return "placeholder" - case .content: return "content" - case .error: return "error" - case .collapsed: return "collapsed" - } - } } // All three methods are implemented on purpose: the protocol gives each an empty default, so a // host that still spelled the old `DidFail(_:)` would compile and silently hear no failure. extension MindboxEmbeddedBlockHost: MindboxEmbeddedBlockViewDelegate { public func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { - onOutcome?("load", nil) + onOutcome?(EmbeddedBlockWire.outcomeLoad as NSString, nil) } public func mindboxEmbeddedBlockViewDidBecomeEmpty(_ blockView: MindboxEmbeddedBlockView) { - onOutcome?("empty", nil) + onOutcome?(EmbeddedBlockWire.outcomeEmpty as NSString, nil) } public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView, - reason: MindboxEmbeddedBlockFailReason) { - onOutcome?("fail", reason.rawValue as NSString) + reason: MindboxEmbeddedBlockFailReason) { + onOutcome?(EmbeddedBlockWire.outcomeFail as NSString, reason.rawValue as NSString) } } diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm index 78e25f1..aa4043e 100644 --- a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm +++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm @@ -34,11 +34,15 @@ @implementation MindboxEmbeddedBlockViewComponentView { std::string _placeSystemName; CGFloat _blockHeight; double _timeoutMs; + std::string _loadingStrategy; + BOOL _animatesReveal; BOOL _hasPlaceholder; BOOL _hasErrorView; BOOL _isHostVisible; NSString *_pendingAppearance; + BOOL _pendingAppearanceAnimated; + NSInteger _pendingAppearanceRevealDurationMs; NSString *_pendingOutcome; NSString *_pendingOutcomeReason; } @@ -59,6 +63,7 @@ - (instancetype)initWithFrame:(CGRect)frame static const auto defaultProps = std::make_shared(); _props = defaultProps; _isHostVisible = YES; + _animatesReveal = YES; } return self; @@ -80,6 +85,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _blockHeight = next.blockHeight; _timeoutMs = next.timeoutMs; + _loadingStrategy = next.loadingStrategy; + _animatesReveal = next.animatesReveal; _hasPlaceholder = next.hasPlaceholder; _hasErrorView = next.hasErrorView; _isHostVisible = next.hostVisible; @@ -102,13 +109,15 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:@(_placeSystemName.c_str()) height:_blockHeight - timeoutMs:_timeoutMs]; + timeoutMs:_timeoutMs + loadingStrategy:@(_loadingStrategy.c_str()) + animatesReveal:_animatesReveal]; [_host setStandInsWithHasPlaceholder:_hasPlaceholder hasErrorView:_hasErrorView]; [_host setHostVisible:_isHostVisible]; __weak MindboxEmbeddedBlockViewComponentView *weakSelf = self; - _host.onAppearance = ^(NSString *appearance) { - [weakSelf emitAppearance:appearance]; + _host.onAppearance = ^(NSString *appearance, BOOL animated, NSInteger revealDurationMs) { + [weakSelf emitAppearance:appearance animated:animated revealDurationMs:revealDurationMs]; }; _host.onOutcome = ^(NSString *outcome, NSString *_Nullable reason) { [weakSelf emitOutcome:outcome reason:reason]; @@ -124,7 +133,7 @@ - (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter if (_pendingAppearance != nil) { NSString *appearance = _pendingAppearance; _pendingAppearance = nil; - [self emitAppearance:appearance]; + [self emitAppearance:appearance animated:_pendingAppearanceAnimated revealDurationMs:_pendingAppearanceRevealDurationMs]; } if (_pendingOutcome != nil) { @@ -142,15 +151,19 @@ - (void)invalidate _placeSystemName = ""; } -- (void)emitAppearance:(NSString *)appearance +- (void)emitAppearance:(NSString *)appearance animated:(BOOL)animated revealDurationMs:(NSInteger)revealDurationMs { if (!_eventEmitter) { _pendingAppearance = appearance; + _pendingAppearanceAnimated = animated; + _pendingAppearanceRevealDurationMs = revealDurationMs; return; } std::static_pointer_cast(_eventEmitter) - ->onAppearanceChange({.appearance = std::string([appearance UTF8String])}); + ->onAppearanceChange({.appearance = std::string([appearance UTF8String]), + .animated = static_cast(animated), + .revealDurationMs = static_cast(animated ? revealDurationMs : 0)}); } - (void)emitOutcome:(NSString *)outcome reason:(NSString *_Nullable)reason @@ -210,6 +223,8 @@ @interface MindboxEmbeddedBlockPaperView : RCTView @property (nonatomic, copy) NSString *placeSystemName; @property (nonatomic, assign) CGFloat blockHeight; @property (nonatomic, assign) double timeoutMs; +@property (nonatomic, copy) NSString *loadingStrategy; +@property (nonatomic, assign) BOOL animatesReveal; @property (nonatomic, assign) BOOL hasPlaceholder; @property (nonatomic, assign) BOOL hasErrorView; @property (nonatomic, assign) BOOL hostVisible; @@ -229,6 +244,7 @@ - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { _hostVisible = YES; + _animatesReveal = YES; } return self; @@ -277,17 +293,25 @@ - (void)buildHostIfPossible // JS side warns the host about a new value rather than applying it. _builtTimeoutMs = _timeoutMs; + // The strategy and the animation flag are taken once as well: the native block takes them only + // through its initializer. _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:_builtPlaceSystemName height:_blockHeight - timeoutMs:_builtTimeoutMs]; + timeoutMs:_builtTimeoutMs + loadingStrategy:_loadingStrategy ?: @"" + animatesReveal:_animatesReveal]; [_host setStandInsWithHasPlaceholder:_hasPlaceholder hasErrorView:_hasErrorView]; [_host setHostVisible:_hostVisible]; __weak MindboxEmbeddedBlockPaperView *weakSelf = self; - _host.onAppearance = ^(NSString *appearance) { + _host.onAppearance = ^(NSString *appearance, BOOL animated, NSInteger revealDurationMs) { MindboxEmbeddedBlockPaperView *strongSelf = weakSelf; if (strongSelf.onAppearanceChange != nil) { - strongSelf.onAppearanceChange(@{@"appearance" : appearance}); + strongSelf.onAppearanceChange(@{ + @"appearance" : appearance, + @"animated" : @(animated), + @"revealDurationMs" : @(animated ? revealDurationMs : 0), + }); } }; _host.onOutcome = ^(NSString *outcome, NSString *_Nullable reason) { @@ -337,6 +361,8 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(placeSystemName, NSString) RCT_EXPORT_VIEW_PROPERTY(blockHeight, CGFloat) RCT_EXPORT_VIEW_PROPERTY(timeoutMs, double) +RCT_EXPORT_VIEW_PROPERTY(loadingStrategy, NSString) +RCT_EXPORT_VIEW_PROPERTY(animatesReveal, BOOL) RCT_EXPORT_VIEW_PROPERTY(hasPlaceholder, BOOL) RCT_EXPORT_VIEW_PROPERTY(hasErrorView, BOOL) RCT_EXPORT_VIEW_PROPERTY(hostVisible, BOOL) diff --git a/ios/MindboxSdk.m b/ios/MindboxSdk.m index 2ebf228..e518b76 100644 --- a/ios/MindboxSdk.m +++ b/ios/MindboxSdk.m @@ -20,6 +20,8 @@ @interface RCT_EXTERN_MODULE(MindboxSdk, NSObject) RCT_EXTERN_METHOD(getSdkVersion:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(embeddedBlockInitialAppearance:(NSString)placeSystemName loadingStrategy:(NSString)loadingStrategy resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(pushDelivered:(NSString)uniqKey) RCT_EXTERN_METHOD(refreshNotificationPermissionStatus) diff --git a/ios/MindboxSdk.swift b/ios/MindboxSdk.swift index 3a26156..e2f5dec 100644 --- a/ios/MindboxSdk.swift +++ b/ios/MindboxSdk.swift @@ -1,4 +1,4 @@ -import Mindbox +@_spi(Internal) import Mindbox import MindboxLogger enum CustomError: Error { @@ -188,6 +188,29 @@ class MindboxSdk: NSObject { } } + /// What an embedded block of this place starts with — the look the native block decides before it + /// exists, from the SDK's memory of the place. Asked by `MindboxEmbeddedBlock` for an `automatic` + /// block, since JS reaches the memory only asynchronously; answered with an appearance word. + /// + /// Internal to the package: not exported by the JS side. + @objc(embeddedBlockInitialAppearance:loadingStrategy:resolver:rejecter:) + func embeddedBlockInitialAppearance(_ placeSystemName: String, + loadingStrategy: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + guard let strategy = EmbeddedBlockWire.loadingStrategy(of: loadingStrategy) else { + reject("bad_arguments", "embeddedBlockInitialAppearance expects a loading strategy word: automatic, placeholder or hidden", nil) + return + } + + // On the main thread, as the native wrappers ask it: the memory is read where the blocks run. + DispatchQueue.main.async { + let appearance = MindboxEmbeddedBlockView.initialAppearance(placeSystemName: placeSystemName, + loadingStrategy: strategy) + resolve(EmbeddedBlockWire.name(of: appearance)) + } + } + @objc(pushDelivered:) func pushDelivered(_ uniqKey: String) { Mindbox.shared.pushDelivered(uniqueKey: uniqKey) diff --git a/src/MindboxEmbeddedBlock.tsx b/src/MindboxEmbeddedBlock.tsx index 3297406..1e9020c 100644 --- a/src/MindboxEmbeddedBlock.tsx +++ b/src/MindboxEmbeddedBlock.tsx @@ -1,10 +1,12 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Platform, StyleSheet, View } from 'react-native' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Animated, Easing, Platform, StyleSheet, View } from 'react-native' import type { StyleProp, ViewStyle } from 'react-native' import MindboxEmbeddedBlockNativeView from './MindboxEmbeddedBlockNativeComponent' import type { NativeProps } from './MindboxEmbeddedBlockNativeComponent' import { MindboxEmbeddedBlockFailReason } from './MindboxEmbeddedBlockFailReason' +import { MindboxEmbeddedBlockLoadingStrategy } from './MindboxEmbeddedBlockLoadingStrategy' +import { askInitialAppearance } from './MindboxEmbeddedBlockNativeModule' /** * The handlers are typed by the props they are handed to, not by a second spelling of the same @@ -70,6 +72,29 @@ export type MindboxEmbeddedBlockProps = { */ timeoutMs?: number + /** + * What the block shows until the SDK answers — see [MindboxEmbeddedBlockLoadingStrategy]. + * + * `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. A block that takes its space up front + * keeps the layout still at the price of flashing where there is nothing to show; a block that + * waits hidden never flashes at the price of the layout growing when content arrives. A place + * that always has a campaign behind it is worth an explicit `placeholder`. + * + * Fixed when the block is created, as [timeoutMs] is: a new value on a live block is ignored with + * a warning. Remount the component (give it a new `key`) to build a block anew. + */ + loadingStrategy?: MindboxEmbeddedBlockLoadingStrategy + + /** + * Whether the SDK animates the reveal of the content — a fade, and the growth of a block that + * waited hidden. `true` by default; the system's reduced-motion setting turns the animation off + * as well. Turn it off to animate the block's container yourself in [onLoad]. + * + * Fixed when the block is created, as [timeoutMs] is. + */ + animatesReveal?: boolean + /** * Drawn instead of the SDK shimmer while the block is loading. Fills the whole place, as the native * placeholder does. @@ -148,6 +173,11 @@ export type MindboxEmbeddedBlockProps = { * and the handlers of the tree the block itself stands in. The wait for an answer is bounded by * [timeoutMs] — 30 seconds unless the host says otherwise. * + * What the block shows until the SDK has decided what goes into it is the [loadingStrategy]: a + * placeholder, nothing, or — by default — nothing until the place has shown content once on this + * device and a placeholder from then on. The content is revealed with the SDK's own animation — it + * fades in, and a block that started hidden grows to its height — unless [animatesReveal] is off. + * * The outcome arrives through three callbacks, the same three as in SwiftUI, Compose and Flutter: * [onLoad] when the content is shown, [onEmpty] when there is nothing to show at the place, and * [onFail] with a reason when the block could not be shown. @@ -161,8 +191,12 @@ export type MindboxEmbeddedBlockProps = { */ export const MindboxEmbeddedBlock = (props: MindboxEmbeddedBlockProps) => -const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, onEmpty, onFail, active = true, style }: MindboxEmbeddedBlockProps) => { - const [appearance, setAppearance] = useState(IS_SUPPORTED ? 'placeholder' : 'collapsed') +const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEmbeddedBlockLoadingStrategy.automatic, animatesReveal = true, placeholder, error, onLoad, onEmpty, onFail, active = true, style }: MindboxEmbeddedBlockProps) => { + const creationTimeoutMs = useRef(timeoutMs).current + const creationLoadingStrategy = useRef(loadingStrategy).current + const creationAnimatesReveal = useRef(animatesReveal).current + + const [appearance, setAppearance] = useState(() => (IS_SUPPORTED ? firstLook(creationLoadingStrategy) : 'collapsed')) const blockHeight = Number.isFinite(height) ? Math.max(0, height) : 0 @@ -183,15 +217,20 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const creationTimeoutMs = useRef(timeoutMs).current - const hasWarnedAboutTimeout = useRef(false) + // The values fixed at creation: a new one is ignored and said once, per value. + const warnedCreationValues = useRef(new Set()) useEffect(() => { - if (timeoutMs === creationTimeoutMs || hasWarnedAboutTimeout.current) { - return + const warnIfIgnored = (name: string, given: unknown, kept: unknown) => { + if (given === kept || warnedCreationValues.current.has(name)) { + return + } + warnedCreationValues.current.add(name) + console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" was given ${name} ${String(given)} after creation and keeps ${String(kept)}: ${name} is fixed when the block is created. Remount the component — give it a new key — to build a block anew.`) } - hasWarnedAboutTimeout.current = true - console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" keeps the timeout it was created with; the new value is ignored. Remount the component — give it a new key — to change the timeout.`) - }, [timeoutMs, creationTimeoutMs, placeSystemName]) + warnIfIgnored('timeoutMs', timeoutMs, creationTimeoutMs) + warnIfIgnored('loadingStrategy', loadingStrategy, creationLoadingStrategy) + warnIfIgnored('animatesReveal', animatesReveal, creationAnimatesReveal) + }, [timeoutMs, creationTimeoutMs, loadingStrategy, creationLoadingStrategy, animatesReveal, creationAnimatesReveal, placeSystemName]) // The callbacks are read through a ref at delivery time, so a host that passes a fresh closure // on every render neither re-subscribes anything nor misses the one delivery of an outcome. @@ -231,13 +270,74 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, } }, [deliver]) - const handleAppearanceChange = useCallback((event) => { - const reported = event.nativeEvent.appearance - if (APPEARANCES.includes(reported)) { - setAppearance(reported as Appearance) + /** The growth of a block that waited hidden: 0 to 1 over the SDK's reveal, 1 at rest. */ + const reveal = useRef(new Animated.Value(1)).current + const shownAppearance = useRef(appearance) + + /** + * Takes the block to [next]. The reveal of content into a slot that was closed is the one change + * that is animated, and only when the native block says so — it owns that decision, gates + * included — for as long as it says; everything else lands at once. + */ + const show = useCallback( + (next: Appearance, revealDurationMs?: number) => { + const opens = shownAppearance.current === 'collapsed' && next === 'content' + shownAppearance.current = next + 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() + } else { + reveal.stopAnimation() + reveal.setValue(1) + } + }, + [reveal] + ) + + /** + * The native block has reported at least once. From then on the first look asked of the native + * module is stale, whenever it arrives. + */ + const hasHeardFromNative = useRef(false) + + // The first look of an `automatic` block is the SDK's memory of the place, which only the native + // side has and JS reaches asynchronously. Asked of the module — not the block, which does not + // exist yet — once, on mount; until it answers the block takes no space. The native block's own + // report, once the native view is built, settles the same question and wins. + useEffect(() => { + if (!IS_SUPPORTED || creationLoadingStrategy !== MindboxEmbeddedBlockLoadingStrategy.automatic) { + return + } + let isMounted = true + askInitialAppearance(placeSystemName, creationLoadingStrategy) + .then((word) => { + if (!isMounted || hasHeardFromNative.current || !APPEARANCES.includes(word) || word === shownAppearance.current) { + return + } + show(word as Appearance) + }) + .catch((reason: unknown) => { + console.warn(`[MindboxEmbeddedBlock] initialAppearance for block "${placeSystemName}" was not answered: ${String(reason)}`) + }) + return () => { + isMounted = false } + // Once, on mount: the strategy is fixed at creation and the name remounts the component. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + const handleAppearanceChange = useCallback( + (event) => { + hasHeardFromNative.current = true + const { appearance: reported, animated, revealDurationMs } = event.nativeEvent + if (APPEARANCES.includes(reported) && reported !== shownAppearance.current) { + show(reported as Appearance, animated ? revealDurationMs : undefined) + } + }, + [show] + ) + const handleOutcome = useCallback( (event) => { const { outcome, reason } = event.nativeEvent @@ -250,29 +350,58 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, const overlay = appearance === 'placeholder' ? placeholder : appearance === 'error' ? error : null + // The height the layout is given: nothing for a collapsed block, the block's height otherwise — + // and, while a block that waited hidden is revealed, the part of it the growth has reached. + const slotHeight = useMemo(() => reveal.interpolate({ inputRange: [0, 1], outputRange: [0, blockHeight] }), [reveal, blockHeight]) + return ( - - {IS_SUPPORTED ? : null} - {overlay != null ? ( - - {overlay} - - ) : null} - + {/* + The slot is what the layout sees; the block inside keeps its full height whatever the slot + is. A native view sized to nothing is never built — both native halves wait for a frame — + so a block that waits hidden would never load and never grow. With its own height under a + clipped slot of zero it runs its whole cycle unseen, as the native blocks do, and the slot + opens when the content arrives. + */} + + {IS_SUPPORTED ? : null} + {overlay != null ? ( + + {overlay} + + ) : null} + + ) } +/** + * The look a block starts with, before the native block exists to say. + * + * `placeholder` and `hidden` are decided by the strategy alone. `automatic` is decided by the SDK's + * memory of the place, which only the native side has and JS reaches asynchronously, so until it + * answers an `automatic` block takes no space: a place that has never shown content must not flash + * reserved space, and one that has shows its placeholder a frame late rather than a frame early. + */ +const firstLook = (strategy: MindboxEmbeddedBlockLoadingStrategy): Appearance => (strategy === MindboxEmbeddedBlockLoadingStrategy.placeholder ? 'placeholder' : 'collapsed') + const styles = StyleSheet.create({ block: { width: '100%', overflow: 'hidden', }, + inside: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, }) export default MindboxEmbeddedBlock diff --git a/src/MindboxEmbeddedBlockLoadingStrategy.ts b/src/MindboxEmbeddedBlockLoadingStrategy.ts new file mode 100644 index 0000000..8d743b6 --- /dev/null +++ b/src/MindboxEmbeddedBlockLoadingStrategy.ts @@ -0,0 +1,39 @@ +/** + * What the block shows until the SDK has decided what goes into it — given at creation. + * + * Every place marked up in the app waits for the SDK on every launch, and a place with no campaign + * behind it would flash a placeholder and collapse each time. The strategy decides whether the + * block takes its space before the answer, and the SDK remembers per place whether content was + * ever shown there, so the layout does not jump where content is expected and does not flash where + * it is not. + * + * The same three values exist on every platform; `automatic` is the default everywhere. + */ +export type MindboxEmbeddedBlockLoadingStrategy = 'automatic' | 'placeholder' | 'hidden' + +export const MindboxEmbeddedBlockLoadingStrategy = { + /** + * Hidden until the place has shown content once on this device; a placeholder from then on. + * + * The memory is per place system name and survives a restart. It is dropped when the place + * answers with nothing to show — the campaign was switched off, the targeting did not match, the + * page rendered nothing — so the next launch starts hidden again. A failure keeps the memory: + * that is "could not", not "nothing here". + * + * The memory lives on the native side and reaches JS a moment after the block is mounted, so an + * `automatic` block takes no space until it has: a place that has shown content before gets its + * placeholder a frame late rather than a place that has not getting a frame of reserved space. + */ + automatic: 'automatic', + + /** Always a placeholder until the answer: the SDK shimmer or the host's own `placeholder`. */ + placeholder: 'placeholder', + + /** + * Hidden until the content is shown once: zero height, no placeholder, and no `error` on a + * failure — a block that never took its space does not take it for an error screen either. The + * outcome still arrives through `onFail`. Once content was shown the space is taken, and a later + * failure may keep it with the host's error screen, like on every platform. + */ + hidden: 'hidden', +} as const diff --git a/src/MindboxEmbeddedBlockNativeComponent.ts b/src/MindboxEmbeddedBlockNativeComponent.ts index 895c71b..a999cfc 100644 --- a/src/MindboxEmbeddedBlockNativeComponent.ts +++ b/src/MindboxEmbeddedBlockNativeComponent.ts @@ -1,9 +1,17 @@ import type { ViewProps } from 'react-native' -import type { DirectEventHandler, Double, WithDefault } from 'react-native/Libraries/Types/CodegenTypes' +import type { DirectEventHandler, Double, Int32, WithDefault } from 'react-native/Libraries/Types/CodegenTypes' import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent' +/** + * The look the native block shows: `placeholder`, `content`, `error` or `collapsed`. `animated` is + * `true` for the one change that is the SDK's reveal of content — the native block owns that + * decision, gates included — and `revealDurationMs` is how long it takes; both are `false` and `0` + * for every other change. + */ type AppearanceChangeEvent = Readonly<{ appearance: string + animated: boolean + revealDurationMs: Int32 }> /** @@ -22,6 +30,11 @@ export interface NativeProps extends ViewProps { timeoutMs?: Double + /** `automatic`, `placeholder` or `hidden`; an empty word means `automatic`. */ + loadingStrategy?: string + + animatesReveal?: WithDefault + hasPlaceholder?: WithDefault hasErrorView?: WithDefault diff --git a/src/MindboxEmbeddedBlockNativeModule.ts b/src/MindboxEmbeddedBlockNativeModule.ts new file mode 100644 index 0000000..e3cf065 --- /dev/null +++ b/src/MindboxEmbeddedBlockNativeModule.ts @@ -0,0 +1,20 @@ +import { NativeModules } from 'react-native' + +/** + * What the native SDK answers about a block before the block exists. + * + * The native blocks decide the first look of an `automatic` block synchronously, from the SDK's + * memory of the place, through a static `initialAppearance`. JS cannot reach anything + * synchronously, so the same question goes over the bridge to the `MindboxSdk` native module — + * the one module the SDK already has, on both renderers — and is answered with an appearance word: + * `placeholder`, `content`, `error` or `collapsed`. + * + * Internal: not exported from the package. The component asks; a host has no reason to. + */ +export const askInitialAppearance = (placeSystemName: string, loadingStrategy: string): Promise => { + const module = NativeModules.MindboxSdk + if (module == null || typeof module.embeddedBlockInitialAppearance !== 'function') { + return Promise.reject(new Error('the MindboxSdk native module has no embeddedBlockInitialAppearance')) + } + return module.embeddedBlockInitialAppearance(placeSystemName, loadingStrategy) +} diff --git a/src/__tests__/MindboxEmbeddedBlock.test.tsx b/src/__tests__/MindboxEmbeddedBlock.test.tsx index 0b26b62..788f214 100644 --- a/src/__tests__/MindboxEmbeddedBlock.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx @@ -5,6 +5,8 @@ import type { ReactTestRenderer } from 'react-test-renderer' import { MindboxEmbeddedBlock } from '../MindboxEmbeddedBlock' import { MindboxEmbeddedBlockFailReason } from '../MindboxEmbeddedBlockFailReason' +import { MindboxEmbeddedBlockLoadingStrategy } from '../MindboxEmbeddedBlockLoadingStrategy' +import { askInitialAppearance } from '../MindboxEmbeddedBlockNativeModule' jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { const ReactActual = require('react') @@ -15,6 +17,26 @@ jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { } }) +jest.mock('../MindboxEmbeddedBlockNativeModule', () => ({ + askInitialAppearance: jest.fn(), +})) + +const firstLook = askInitialAppearance as jest.MockedFunction + +/** The module never answers: an `automatic` block stays as it started until the native block reports. */ +const holdFirstLook = () => firstLook.mockImplementation(() => new Promise(() => undefined)) + +const answerFirstLookWith = (word: string) => firstLook.mockResolvedValue(word) + +const forgetFirstLook = () => firstLook.mockRejectedValue(new Error('no module')) + +/** Lets the module's answer, a promise, reach the component. */ +const settle = async () => { + await act(async () => { + await Promise.resolve() + }) +} + const render = (element: React.ReactElement): ReactTestRenderer => { let renderer: ReactTestRenderer act(() => { @@ -33,9 +55,9 @@ const asType = (component: unknown) => component as any const nativeProps = (renderer: ReactTestRenderer) => renderer.root.findByProps({ testID: 'native-block' }).props -const reportAppearance = (renderer: ReactTestRenderer, appearance: string) => { +const reportAppearance = (renderer: ReactTestRenderer, appearance: string, reveal: { animated: boolean; revealDurationMs: number } = { animated: false, revealDurationMs: 0 }) => { act(() => { - nativeProps(renderer).onAppearanceChange({ nativeEvent: { appearance } }) + nativeProps(renderer).onAppearanceChange({ nativeEvent: { appearance, ...reveal } }) }) } @@ -46,14 +68,28 @@ const reportOutcome = (renderer: ReactTestRenderer, outcome: string, reason = '' }) } +/** The height the layout is given: the slot the block stands in. */ const frameHeight = (renderer: ReactTestRenderer) => { const frame = renderer.root.findAllByType(asType(View))[0] return StyleSheet.flatten(frame.props.style).height } +/** The height of the block inside the slot — what the native view is laid out in. */ +const insideHeight = (renderer: ReactTestRenderer) => { + const inside = renderer.root.findAllByType(asType(View))[1] + return StyleSheet.flatten(inside.props.style).height +} + +const placeholderBlock = (props: Partial> = {}) => + +beforeEach(() => { + firstLook.mockReset() + holdFirstLook() +}) + describe('MindboxEmbeddedBlock', () => { it('takes its height while loading and hands it back when the block collapses', () => { - const renderer = render() + const renderer = render(placeholderBlock()) expect(frameHeight(renderer)).toBe(104) @@ -63,19 +99,20 @@ describe('MindboxEmbeddedBlock', () => { }) it('resizes a live block in place when the height changes', () => { - const renderer = render() + const renderer = render(placeholderBlock({ height: 160 })) - update(renderer, ) + update(renderer, placeholderBlock({ height: 80 })) expect(frameHeight(renderer)).toBe(80) + expect(insideHeight(renderer)).toBe(80) expect(nativeProps(renderer).blockHeight).toBe(80) }) it('says nothing about space around a name the SDK trims anyway', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) - const renderer = render() + const renderer = render(placeholderBlock({ placeSystemName: ' stories ' })) - update(renderer, ) + update(renderer, placeholderBlock({ placeSystemName: ' stories ' })) expect(warn).not.toHaveBeenCalled() expect(nativeProps(renderer).placeSystemName).toBe(' stories ') @@ -84,9 +121,9 @@ describe('MindboxEmbeddedBlock', () => { it('warns once about a place system name that is not there at all', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) - const renderer = render() + const renderer = render(placeholderBlock({ placeSystemName: '' })) - update(renderer, ) + update(renderer, placeholderBlock({ placeSystemName: '' })) expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0][0]).toContain('without a place system name') @@ -96,7 +133,7 @@ describe('MindboxEmbeddedBlock', () => { it('calls a name of nothing but spaces a missing name, not a padded one', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) - render() + render(placeholderBlock({ placeSystemName: ' ' })) expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0][0]).toContain('without a place system name') @@ -106,7 +143,7 @@ describe('MindboxEmbeddedBlock', () => { it('hands the failure of a nameless place to the host and gives the space back', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ placeSystemName: '', onFail })) reportAppearance(renderer, 'collapsed') reportOutcome(renderer, 'fail', 'internalError') @@ -123,7 +160,7 @@ describe('MindboxEmbeddedBlock', () => { ['not a number', Number.NaN], ])('warns about a %s height that reserves no space and hands the layout zero', (_name, height) => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) - const renderer = render() + const renderer = render(placeholderBlock({ height })) expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0][0]).toContain('reserves no space') @@ -133,7 +170,7 @@ describe('MindboxEmbeddedBlock', () => { }) it('tells the native block the place, the height and whether the place is taken', () => { - const renderer = render(wait} active={false} />) + const renderer = render(placeholderBlock({ placeholder: wait, active: false })) expect(nativeProps(renderer)).toMatchObject({ placeSystemName: 'stories', @@ -145,31 +182,32 @@ describe('MindboxEmbeddedBlock', () => { }) it('sends zero for a timeout the host did not set', () => { - const renderer = render() + const renderer = render(placeholderBlock()) expect(nativeProps(renderer).timeoutMs).toBe(0) }) it('sends the timeout it was created with, in milliseconds', () => { - const renderer = render() + const renderer = render(placeholderBlock({ timeoutMs: 5000 })) expect(nativeProps(renderer).timeoutMs).toBe(5000) }) it('keeps the timeout it was created with and warns once about a change', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) - const renderer = render() + const renderer = render(placeholderBlock({ timeoutMs: 5000 })) - update(renderer, ) - update(renderer, ) + update(renderer, placeholderBlock({ timeoutMs: 1000 })) + update(renderer, placeholderBlock({ timeoutMs: 2000 })) expect(nativeProps(renderer).timeoutMs).toBe(5000) expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain('timeoutMs') warn.mockRestore() }) it('draws the host placeholder over the loading block and the host error over the failed one', () => { - const renderer = render(loading} error={broken} />) + const renderer = render(placeholderBlock({ placeholder: loading, error: broken })) expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') @@ -186,7 +224,7 @@ describe('MindboxEmbeddedBlock', () => { it('delivers each outcome once and a changed outcome again', () => { const onLoad = jest.fn() const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onLoad, onFail })) reportOutcome(renderer, 'load') reportOutcome(renderer, 'load') @@ -201,7 +239,7 @@ describe('MindboxEmbeddedBlock', () => { it('reports an empty place through onEmpty, with the space given back and no reason', () => { const onEmpty = jest.fn() const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onEmpty, onFail })) reportAppearance(renderer, 'collapsed') reportOutcome(renderer, 'empty') @@ -214,7 +252,7 @@ describe('MindboxEmbeddedBlock', () => { it('hands the failure reason to onFail as the native block spelled it', () => { const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onFail })) reportOutcome(renderer, 'fail', 'networkError') @@ -223,7 +261,7 @@ describe('MindboxEmbeddedBlock', () => { it('passes a reason it does not know through as it is', () => { const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onFail })) reportOutcome(renderer, 'fail', 'sideways') @@ -232,7 +270,7 @@ describe('MindboxEmbeddedBlock', () => { it('calls a failure without a reason an internal error', () => { const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onFail })) reportOutcome(renderer, 'fail') @@ -241,7 +279,7 @@ describe('MindboxEmbeddedBlock', () => { it('delivers a failure that repeats with another reason once', () => { const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onFail })) reportOutcome(renderer, 'fail', 'networkError') reportOutcome(renderer, 'fail', 'internalError') @@ -252,7 +290,7 @@ describe('MindboxEmbeddedBlock', () => { it('delivers a sequence of outcomes in order, repeats dropped', () => { const delivered: Array = [] - const renderer = render( delivered.push('load')} onEmpty={() => delivered.push('empty')} onFail={(reason) => delivered.push(`fail:${reason}`)} />) + const renderer = render(placeholderBlock({ onLoad: () => delivered.push('load'), onEmpty: () => delivered.push('empty'), onFail: (reason) => delivered.push(`fail:${reason}`) })) reportOutcome(renderer, 'empty') reportOutcome(renderer, 'empty') @@ -266,7 +304,7 @@ describe('MindboxEmbeddedBlock', () => { const onLoad = jest.fn() const onEmpty = jest.fn() const onFail = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onLoad, onEmpty, onFail })) reportOutcome(renderer, 'sideways') @@ -278,9 +316,9 @@ describe('MindboxEmbeddedBlock', () => { it('calls the handler the host passed last, not the one it passed first', () => { const first = jest.fn() const second = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onLoad: first })) - update(renderer, ) + update(renderer, placeholderBlock({ onLoad: second })) reportOutcome(renderer, 'load') expect(first).not.toHaveBeenCalled() @@ -289,7 +327,7 @@ describe('MindboxEmbeddedBlock', () => { }) it('ignores an appearance it does not know, keeping the last known one', () => { - const renderer = render() + const renderer = render(placeholderBlock()) reportAppearance(renderer, 'collapsed') reportAppearance(renderer, 'sideways') @@ -299,14 +337,203 @@ describe('MindboxEmbeddedBlock', () => { it('builds a different place as a different block, with nothing remembered', () => { const onLoad = jest.fn() - const renderer = render() + const renderer = render(placeholderBlock({ onLoad })) reportOutcome(renderer, 'load') - update(renderer, ) + update(renderer, placeholderBlock({ placeSystemName: 'banner', onLoad })) reportOutcome(renderer, 'load') expect(onLoad).toHaveBeenCalledTimes(2) }) + + describe('the strategy and the animation flag', () => { + it('tells the native block the strategy and the flag it was created with', () => { + const renderer = render() + + expect(nativeProps(renderer)).toMatchObject({ loadingStrategy: 'hidden', animatesReveal: false }) + }) + + it('starts automatic and animated when the host says nothing', () => { + const renderer = render() + + expect(nativeProps(renderer)).toMatchObject({ loadingStrategy: 'automatic', animatesReveal: true }) + }) + + it('names the strategies by the words the native blocks take', () => { + expect(MindboxEmbeddedBlockLoadingStrategy).toEqual({ automatic: 'automatic', placeholder: 'placeholder', hidden: 'hidden' }) + }) + + it('keeps the strategy and the flag it was created with and warns once about each change', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const renderer = render(placeholderBlock({ animatesReveal: true })) + + update(renderer, ) + update(renderer, ) + + expect(nativeProps(renderer)).toMatchObject({ loadingStrategy: 'placeholder', animatesReveal: true }) + expect(warn).toHaveBeenCalledTimes(2) + expect(warn.mock.calls[0][0]).toContain('loadingStrategy') + expect(warn.mock.calls[1][0]).toContain('animatesReveal') + warn.mockRestore() + }) + }) + + describe('the first look', () => { + it('gives a placeholder block its height from the first frame and never asks', () => { + const renderer = render(placeholderBlock({ placeholder: loading })) + + expect(frameHeight(renderer)).toBe(104) + expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') + expect(firstLook).not.toHaveBeenCalled() + }) + + it('gives a hidden block no slot while the native view inside keeps the full height', () => { + const renderer = render(loading} />) + + expect(frameHeight(renderer)).toBe(0) + expect(insideHeight(renderer)).toBe(104) + expect(nativeProps(renderer).blockHeight).toBe(104) + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + expect(firstLook).not.toHaveBeenCalled() + }) + + it('asks the native module once what an automatic block starts with', () => { + render() + + expect(firstLook).toHaveBeenCalledTimes(1) + expect(firstLook).toHaveBeenCalledWith('stories', 'automatic') + }) + + it('keeps an automatic block at zero until the answer, then shows the placeholder', async () => { + let answer: (word: string) => void = () => undefined + firstLook.mockImplementation(() => new Promise((resolve) => (answer = resolve))) + const renderer = render(loading} />) + + expect(frameHeight(renderer)).toBe(0) + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + + answer('placeholder') + await settle() + + expect(frameHeight(renderer)).toBe(104) + expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') + }) + + it('keeps an automatic block at zero after an answer of collapsed', async () => { + answerFirstLookWith('collapsed') + const renderer = render() + + await settle() + + expect(frameHeight(renderer)).toBe(0) + }) + + it('lets the native block outrank a later answer of the module', async () => { + let answer: (word: string) => void = () => undefined + firstLook.mockImplementation(() => new Promise((resolve) => (answer = resolve))) + const renderer = render() + + reportAppearance(renderer, 'content') + answer('collapsed') + await settle() + + expect(frameHeight(renderer)).toBe(104) + }) + + it('leaves the block at zero when the module fails, says so once, and waits for the native block', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + forgetFirstLook() + const renderer = render() + + await settle() + + expect(frameHeight(renderer)).toBe(0) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain('initialAppearance') + + reportAppearance(renderer, 'placeholder') + + expect(frameHeight(renderer)).toBe(104) + warn.mockRestore() + }) + }) + + describe('the reveal', () => { + const hiddenBlock = () => + + beforeEach(() => { + jest.useFakeTimers('modern') + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const tick = (ms: number) => { + act(() => { + jest.advanceTimersByTime(ms) + }) + } + + it('grows a block that waited hidden from zero to its height over the SDK reveal', () => { + const renderer = render(hiddenBlock()) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + + expect(frameHeight(renderer)).toBe(0) + + tick(125) + + expect(frameHeight(renderer)).toBeGreaterThan(30) + expect(frameHeight(renderer)).toBeLessThan(74) + + tick(125) + + expect(frameHeight(renderer)).toBe(104) + }) + + it('lands at once when the native block does not call the change a reveal', () => { + const renderer = render(hiddenBlock()) + + reportAppearance(renderer, 'content') + + expect(frameHeight(renderer)).toBe(104) + }) + + it('lands at once when the reveal comes without a duration', () => { + const renderer = render(hiddenBlock()) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 0 }) + + expect(frameHeight(renderer)).toBe(104) + }) + + it('does not grow content that arrives into a placeholder: the space was taken already', () => { + const renderer = render(placeholderBlock()) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + + expect(frameHeight(renderer)).toBe(104) + + tick(125) + + expect(frameHeight(renderer)).toBe(104) + }) + + it('collapses at once in the middle of a reveal', () => { + const renderer = render(hiddenBlock()) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + tick(125) + reportAppearance(renderer, 'collapsed') + + expect(frameHeight(renderer)).toBe(0) + + tick(250) + + expect(frameHeight(renderer)).toBe(0) + }) + }) }) describe('MindboxEmbeddedBlockFailReason', () => { diff --git a/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx b/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx index 6f9a2ae..410bcc5 100644 --- a/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx @@ -13,6 +13,8 @@ jest.mock('react-native/Libraries/Utilities/Platform', () => ({ select: (specifics: Record) => specifics.default, })) +jest.mock('../MindboxEmbeddedBlockNativeModule', () => ({ askInitialAppearance: jest.fn(() => new Promise(() => undefined)) })) + jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { const ReactActual = require('react') const { View: RNView } = require('react-native') diff --git a/src/index.tsx b/src/index.tsx index fc74cb5..422afce 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -499,3 +499,5 @@ export { MindboxEmbeddedBlock } from './MindboxEmbeddedBlock' export type { MindboxEmbeddedBlockProps } from './MindboxEmbeddedBlock' export { MindboxEmbeddedBlockFailReason } from './MindboxEmbeddedBlockFailReason' + +export { MindboxEmbeddedBlockLoadingStrategy } from './MindboxEmbeddedBlockLoadingStrategy' From ee276d8512acd05576a450496bd098a62756a15b Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 1 Oct 2026 22:04:19 +0500 Subject: [PATCH 03/10] MOBILE-557: Say that whitespace around a place name is the native block's to ignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/MindboxEmbeddedBlock.tsx | 8 +++++--- src/__tests__/MindboxEmbeddedBlock.test.tsx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/MindboxEmbeddedBlock.tsx b/src/MindboxEmbeddedBlock.tsx index 1e9020c..2a49bfc 100644 --- a/src/MindboxEmbeddedBlock.tsx +++ b/src/MindboxEmbeddedBlock.tsx @@ -39,9 +39,11 @@ export type MindboxEmbeddedBlockProps = { * The name of the place from the admin panel. A different name is a different block, built from * scratch in place of the old one. * - * Space around the name is not part of it: the SDK trims the name before resolving by it, on both - * platforms. A name that is empty — or nothing but spaces — is then no name at all, so the place - * resolves to nothing, collapses and reports [onFail]. The component warns about that. + * Passed down as given. Whitespace around the name is not part of it: the native blocks ignore + * it, so a name pasted from the admin panel with a stray space still finds its place. The name + * itself is matched the way the native SDK matches it. A name that is empty — or nothing but + * spaces — is no name at all, so the place resolves to nothing, collapses and reports [onFail]; + * the component warns about that. */ placeSystemName: string diff --git a/src/__tests__/MindboxEmbeddedBlock.test.tsx b/src/__tests__/MindboxEmbeddedBlock.test.tsx index 788f214..15d4fe7 100644 --- a/src/__tests__/MindboxEmbeddedBlock.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx @@ -108,7 +108,7 @@ describe('MindboxEmbeddedBlock', () => { expect(nativeProps(renderer).blockHeight).toBe(80) }) - it('says nothing about space around a name the SDK trims anyway', () => { + it('passes a name with spaces around it to the native block as given, and says nothing about it', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) const renderer = render(placeholderBlock({ placeSystemName: ' stories ' })) From 122511f796880401eba4da9adc63385c69a7f808 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 5 Oct 2026 10:26:45 +0500 Subject: [PATCH 04/10] MOBILE-557: Let the native block alone tell an automatic block its first look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../java/com/mindboxsdk/MindboxSdkModule.kt | 34 -------- ios/MindboxSdk.m | 2 - ios/MindboxSdk.swift | 25 +----- src/MindboxEmbeddedBlock.tsx | 41 +--------- src/MindboxEmbeddedBlockNativeModule.ts | 20 ----- src/__tests__/MindboxEmbeddedBlock.test.tsx | 79 ++----------------- .../MindboxEmbeddedBlock.unsupported.test.tsx | 2 - 7 files changed, 11 insertions(+), 192 deletions(-) delete mode 100644 src/MindboxEmbeddedBlockNativeModule.ts diff --git a/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt b/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt index 1641a05..5797f5d 100644 --- a/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt +++ b/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt @@ -22,9 +22,6 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableArray import com.facebook.react.modules.core.DeviceEventManagerModule -import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi -import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView -import com.mindboxsdk.embedded.EmbeddedBlockWire import org.json.JSONObject class MindboxSdkModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -228,37 +225,6 @@ class MindboxSdkModule(private val reactContext: ReactApplicationContext) : Reac } } - /** - * What an embedded block of this place starts with — the look the native block decides before it - * exists, from the SDK's memory of the place. Asked by `MindboxEmbeddedBlock` for an `automatic` - * block, since JS reaches the memory only asynchronously; answered with an appearance word. - * - * Internal to the package: not exported by the JS side. - */ - @OptIn(InternalMindboxApi::class) - @ReactMethod - fun embeddedBlockInitialAppearance(placeSystemName: String, loadingStrategy: String, promise: Promise) { - val strategy = EmbeddedBlockWire.loadingStrategyOf(loadingStrategy) - if (strategy == null) { - promise.reject("bad_arguments", "embeddedBlockInitialAppearance expects a loading strategy word: automatic, placeholder or hidden") - return - } - - // On the main thread, as the native wrappers ask it: the memory is read where the blocks run. - Handler(reactContext.mainLooper).post { - try { - val appearance = MindboxEmbeddedBlockView.initialAppearance( - context = reactContext.applicationContext, - placeSystemName = placeSystemName, - loadingStrategy = strategy, - ) - promise.resolve(EmbeddedBlockWire.nameOf(appearance)) - } catch (error: Throwable) { - promise.reject(error) - } - } - } - @ReactMethod fun pushDelivered(uniqKey: String) { Mindbox.onPushReceived( diff --git a/ios/MindboxSdk.m b/ios/MindboxSdk.m index e518b76..2ebf228 100644 --- a/ios/MindboxSdk.m +++ b/ios/MindboxSdk.m @@ -20,8 +20,6 @@ @interface RCT_EXTERN_MODULE(MindboxSdk, NSObject) RCT_EXTERN_METHOD(getSdkVersion:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(embeddedBlockInitialAppearance:(NSString)placeSystemName loadingStrategy:(NSString)loadingStrategy resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - RCT_EXTERN_METHOD(pushDelivered:(NSString)uniqKey) RCT_EXTERN_METHOD(refreshNotificationPermissionStatus) diff --git a/ios/MindboxSdk.swift b/ios/MindboxSdk.swift index e2f5dec..3a26156 100644 --- a/ios/MindboxSdk.swift +++ b/ios/MindboxSdk.swift @@ -1,4 +1,4 @@ -@_spi(Internal) import Mindbox +import Mindbox import MindboxLogger enum CustomError: Error { @@ -188,29 +188,6 @@ class MindboxSdk: NSObject { } } - /// What an embedded block of this place starts with — the look the native block decides before it - /// exists, from the SDK's memory of the place. Asked by `MindboxEmbeddedBlock` for an `automatic` - /// block, since JS reaches the memory only asynchronously; answered with an appearance word. - /// - /// Internal to the package: not exported by the JS side. - @objc(embeddedBlockInitialAppearance:loadingStrategy:resolver:rejecter:) - func embeddedBlockInitialAppearance(_ placeSystemName: String, - loadingStrategy: String, - resolver resolve: @escaping RCTPromiseResolveBlock, - rejecter reject: @escaping RCTPromiseRejectBlock) { - guard let strategy = EmbeddedBlockWire.loadingStrategy(of: loadingStrategy) else { - reject("bad_arguments", "embeddedBlockInitialAppearance expects a loading strategy word: automatic, placeholder or hidden", nil) - return - } - - // On the main thread, as the native wrappers ask it: the memory is read where the blocks run. - DispatchQueue.main.async { - let appearance = MindboxEmbeddedBlockView.initialAppearance(placeSystemName: placeSystemName, - loadingStrategy: strategy) - resolve(EmbeddedBlockWire.name(of: appearance)) - } - } - @objc(pushDelivered:) func pushDelivered(_ uniqKey: String) { Mindbox.shared.pushDelivered(uniqueKey: uniqKey) diff --git a/src/MindboxEmbeddedBlock.tsx b/src/MindboxEmbeddedBlock.tsx index 2a49bfc..9ad1a31 100644 --- a/src/MindboxEmbeddedBlock.tsx +++ b/src/MindboxEmbeddedBlock.tsx @@ -6,7 +6,6 @@ import MindboxEmbeddedBlockNativeView from './MindboxEmbeddedBlockNativeComponen import type { NativeProps } from './MindboxEmbeddedBlockNativeComponent' import { MindboxEmbeddedBlockFailReason } from './MindboxEmbeddedBlockFailReason' import { MindboxEmbeddedBlockLoadingStrategy } from './MindboxEmbeddedBlockLoadingStrategy' -import { askInitialAppearance } from './MindboxEmbeddedBlockNativeModule' /** * The handlers are typed by the props they are handed to, not by a second spelling of the same @@ -297,41 +296,8 @@ const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEm [reveal] ) - /** - * The native block has reported at least once. From then on the first look asked of the native - * module is stale, whenever it arrives. - */ - const hasHeardFromNative = useRef(false) - - // The first look of an `automatic` block is the SDK's memory of the place, which only the native - // side has and JS reaches asynchronously. Asked of the module — not the block, which does not - // exist yet — once, on mount; until it answers the block takes no space. The native block's own - // report, once the native view is built, settles the same question and wins. - useEffect(() => { - if (!IS_SUPPORTED || creationLoadingStrategy !== MindboxEmbeddedBlockLoadingStrategy.automatic) { - return - } - let isMounted = true - askInitialAppearance(placeSystemName, creationLoadingStrategy) - .then((word) => { - if (!isMounted || hasHeardFromNative.current || !APPEARANCES.includes(word) || word === shownAppearance.current) { - return - } - show(word as Appearance) - }) - .catch((reason: unknown) => { - console.warn(`[MindboxEmbeddedBlock] initialAppearance for block "${placeSystemName}" was not answered: ${String(reason)}`) - }) - return () => { - isMounted = false - } - // Once, on mount: the strategy is fixed at creation and the name remounts the component. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - const handleAppearanceChange = useCallback( (event) => { - hasHeardFromNative.current = true const { appearance: reported, animated, revealDurationMs } = event.nativeEvent if (APPEARANCES.includes(reported) && reported !== shownAppearance.current) { show(reported as Appearance, animated ? revealDurationMs : undefined) @@ -387,9 +353,10 @@ const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEm * The look a block starts with, before the native block exists to say. * * `placeholder` and `hidden` are decided by the strategy alone. `automatic` is decided by the SDK's - * memory of the place, which only the native side has and JS reaches asynchronously, so until it - * answers an `automatic` block takes no space: a place that has never shown content must not flash - * reserved space, and one that has shows its placeholder a frame late rather than a frame early. + * memory of the place, which only the native side has: the native block reads it as it is built and + * reports its first look right away. Until that report an `automatic` block takes no space: a place + * that has never shown content must not flash reserved space, and one that has shows its placeholder + * a frame late rather than a frame early. */ const firstLook = (strategy: MindboxEmbeddedBlockLoadingStrategy): Appearance => (strategy === MindboxEmbeddedBlockLoadingStrategy.placeholder ? 'placeholder' : 'collapsed') diff --git a/src/MindboxEmbeddedBlockNativeModule.ts b/src/MindboxEmbeddedBlockNativeModule.ts deleted file mode 100644 index e3cf065..0000000 --- a/src/MindboxEmbeddedBlockNativeModule.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NativeModules } from 'react-native' - -/** - * What the native SDK answers about a block before the block exists. - * - * The native blocks decide the first look of an `automatic` block synchronously, from the SDK's - * memory of the place, through a static `initialAppearance`. JS cannot reach anything - * synchronously, so the same question goes over the bridge to the `MindboxSdk` native module — - * the one module the SDK already has, on both renderers — and is answered with an appearance word: - * `placeholder`, `content`, `error` or `collapsed`. - * - * Internal: not exported from the package. The component asks; a host has no reason to. - */ -export const askInitialAppearance = (placeSystemName: string, loadingStrategy: string): Promise => { - const module = NativeModules.MindboxSdk - if (module == null || typeof module.embeddedBlockInitialAppearance !== 'function') { - return Promise.reject(new Error('the MindboxSdk native module has no embeddedBlockInitialAppearance')) - } - return module.embeddedBlockInitialAppearance(placeSystemName, loadingStrategy) -} diff --git a/src/__tests__/MindboxEmbeddedBlock.test.tsx b/src/__tests__/MindboxEmbeddedBlock.test.tsx index 15d4fe7..c40f720 100644 --- a/src/__tests__/MindboxEmbeddedBlock.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx @@ -6,7 +6,6 @@ import type { ReactTestRenderer } from 'react-test-renderer' import { MindboxEmbeddedBlock } from '../MindboxEmbeddedBlock' import { MindboxEmbeddedBlockFailReason } from '../MindboxEmbeddedBlockFailReason' import { MindboxEmbeddedBlockLoadingStrategy } from '../MindboxEmbeddedBlockLoadingStrategy' -import { askInitialAppearance } from '../MindboxEmbeddedBlockNativeModule' jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { const ReactActual = require('react') @@ -17,26 +16,6 @@ jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { } }) -jest.mock('../MindboxEmbeddedBlockNativeModule', () => ({ - askInitialAppearance: jest.fn(), -})) - -const firstLook = askInitialAppearance as jest.MockedFunction - -/** The module never answers: an `automatic` block stays as it started until the native block reports. */ -const holdFirstLook = () => firstLook.mockImplementation(() => new Promise(() => undefined)) - -const answerFirstLookWith = (word: string) => firstLook.mockResolvedValue(word) - -const forgetFirstLook = () => firstLook.mockRejectedValue(new Error('no module')) - -/** Lets the module's answer, a promise, reach the component. */ -const settle = async () => { - await act(async () => { - await Promise.resolve() - }) -} - const render = (element: React.ReactElement): ReactTestRenderer => { let renderer: ReactTestRenderer act(() => { @@ -82,11 +61,6 @@ const insideHeight = (renderer: ReactTestRenderer) => { const placeholderBlock = (props: Partial> = {}) => -beforeEach(() => { - firstLook.mockReset() - holdFirstLook() -}) - describe('MindboxEmbeddedBlock', () => { it('takes its height while loading and hands it back when the block collapses', () => { const renderer = render(placeholderBlock()) @@ -379,12 +353,11 @@ describe('MindboxEmbeddedBlock', () => { }) describe('the first look', () => { - it('gives a placeholder block its height from the first frame and never asks', () => { + it('gives a placeholder block its height from the first frame', () => { const renderer = render(placeholderBlock({ placeholder: loading })) expect(frameHeight(renderer)).toBe(104) expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') - expect(firstLook).not.toHaveBeenCalled() }) it('gives a hidden block no slot while the native view inside keeps the full height', () => { @@ -394,67 +367,27 @@ describe('MindboxEmbeddedBlock', () => { expect(insideHeight(renderer)).toBe(104) expect(nativeProps(renderer).blockHeight).toBe(104) expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) - expect(firstLook).not.toHaveBeenCalled() }) - it('asks the native module once what an automatic block starts with', () => { - render() - - expect(firstLook).toHaveBeenCalledTimes(1) - expect(firstLook).toHaveBeenCalledWith('stories', 'automatic') - }) - - it('keeps an automatic block at zero until the answer, then shows the placeholder', async () => { - let answer: (word: string) => void = () => undefined - firstLook.mockImplementation(() => new Promise((resolve) => (answer = resolve))) + it('keeps an automatic block at zero until the native block reports a placeholder, then shows it', () => { const renderer = render(loading} />) expect(frameHeight(renderer)).toBe(0) + expect(insideHeight(renderer)).toBe(104) expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) - answer('placeholder') - await settle() + reportAppearance(renderer, 'placeholder') expect(frameHeight(renderer)).toBe(104) expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') }) - it('keeps an automatic block at zero after an answer of collapsed', async () => { - answerFirstLookWith('collapsed') + it('keeps an automatic block at zero when the native block reports collapsed', () => { const renderer = render() - await settle() - - expect(frameHeight(renderer)).toBe(0) - }) - - it('lets the native block outrank a later answer of the module', async () => { - let answer: (word: string) => void = () => undefined - firstLook.mockImplementation(() => new Promise((resolve) => (answer = resolve))) - const renderer = render() - - reportAppearance(renderer, 'content') - answer('collapsed') - await settle() - - expect(frameHeight(renderer)).toBe(104) - }) - - it('leaves the block at zero when the module fails, says so once, and waits for the native block', async () => { - const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) - forgetFirstLook() - const renderer = render() - - await settle() + reportAppearance(renderer, 'collapsed') expect(frameHeight(renderer)).toBe(0) - expect(warn).toHaveBeenCalledTimes(1) - expect(warn.mock.calls[0][0]).toContain('initialAppearance') - - reportAppearance(renderer, 'placeholder') - - expect(frameHeight(renderer)).toBe(104) - warn.mockRestore() }) }) diff --git a/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx b/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx index 410bcc5..6f9a2ae 100644 --- a/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.unsupported.test.tsx @@ -13,8 +13,6 @@ jest.mock('react-native/Libraries/Utilities/Platform', () => ({ select: (specifics: Record) => specifics.default, })) -jest.mock('../MindboxEmbeddedBlockNativeModule', () => ({ askInitialAppearance: jest.fn(() => new Promise(() => undefined)) })) - jest.mock('../MindboxEmbeddedBlockNativeComponent', () => { const ReactActual = require('react') const { View: RNView } = require('react-native') From fd36c6b0ee48ae6f9cbe08586cc0eb013d1559f8 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 5 Oct 2026 10:26:47 +0500 Subject: [PATCH 05/10] MOBILE-557: Hand the host its callbacks at creation so the block's first look is not lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../MindboxEmbeddedBlockHost.swift | 38 ++++-- ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm | 116 +++++++----------- 2 files changed, 72 insertions(+), 82 deletions(-) diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift index a3c23bb..dc3bd71 100644 --- a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift +++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift @@ -5,10 +5,12 @@ import MindboxLogger @objc(MindboxEmbeddedBlockHost) public final class MindboxEmbeddedBlockHost: NSObject { /// The appearance word, whether this change is the SDK's animated reveal, and how long it takes. - @objc public var onAppearance: ((NSString, Bool, Int) -> Void)? + /// Given at creation and never replaced: the block reports its first look the moment it is + /// observed, and a callback wired afterwards would miss it. + private let onAppearance: (NSString, Bool, Int) -> Void /// The outcome word and, for a failure, the reason's raw value. - @objc public var onOutcome: ((NSString, NSString?) -> Void)? + private let onOutcome: (NSString, NSString?) -> Void @objc public var view: UIView { blockView } @@ -18,11 +20,18 @@ public final class MindboxEmbeddedBlockHost: NSObject { /// `loadingStrategy` is a word — `automatic`, `placeholder` or `hidden`; a word this SDK does not /// know is logged and read as `automatic`. Both it and `animatesReveal` are fixed here, as the /// native block takes them only through its initializer. + /// + /// The callbacks are taken here too, not assigned later: subscribing to the block hands out its + /// current look at once — for an `automatic` block, the place's memory — and that first report + /// is the one JS has no other way to learn. Android wires its emitters before the block exists + /// for the same reason. @objc public init(placeSystemName: String, height: CGFloat, timeoutMs: Double, loadingStrategy: String, - animatesReveal: Bool) { + animatesReveal: Bool, + onAppearance: @escaping (NSString, Bool, Int) -> Void, + onOutcome: @escaping (NSString, NSString?) -> Void) { let timeout: TimeInterval? = timeoutMs == 0 ? nil : timeoutMs / 1000 let strategy = EmbeddedBlockWire.loadingStrategy(of: loadingStrategy) blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, @@ -30,6 +39,8 @@ public final class MindboxEmbeddedBlockHost: NSObject { loadingStrategy: strategy ?? .automatic, timeout: timeout, animatesReveal: animatesReveal) + self.onAppearance = onAppearance + self.onOutcome = onOutcome super.init() if placeSystemName.isEmpty { @@ -48,11 +59,13 @@ public final class MindboxEmbeddedBlockHost: NSObject { // calls, for that one call. The block owns the gates — `animatesReveal`, a window to animate // in, Reduce Motion off, "only the arrival of content" — and this host only passes its word // on; the growth of a block that waited hidden is then the JS side's. + // The observer fires synchronously with the current look, so this comes last in `init`, + // with both callbacks already in place. blockView.setAppearanceObserver { [weak self] appearance in - guard let self else { return } - self.onAppearance?(EmbeddedBlockWire.name(of: appearance) as NSString, - self.blockView.isRevealAnimated, - EmbeddedBlockWire.revealDurationMs) + guard let self, !self.isTornDown else { return } + self.onAppearance(EmbeddedBlockWire.name(of: appearance) as NSString, + self.blockView.isRevealAnimated, + EmbeddedBlockWire.revealDurationMs) } } @@ -82,8 +95,6 @@ public final class MindboxEmbeddedBlockHost: NSObject { guard !isTornDown else { return } isTornDown = true - onAppearance = nil - onOutcome = nil blockView.setAppearanceObserver(nil) blockView.delegate = nil blockView.release() @@ -101,15 +112,18 @@ public final class MindboxEmbeddedBlockHost: NSObject { // host that still spelled the old `DidFail(_:)` would compile and silently hear no failure. extension MindboxEmbeddedBlockHost: MindboxEmbeddedBlockViewDelegate { public func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { - onOutcome?(EmbeddedBlockWire.outcomeLoad as NSString, nil) + guard !isTornDown else { return } + onOutcome(EmbeddedBlockWire.outcomeLoad as NSString, nil) } public func mindboxEmbeddedBlockViewDidBecomeEmpty(_ blockView: MindboxEmbeddedBlockView) { - onOutcome?(EmbeddedBlockWire.outcomeEmpty as NSString, nil) + guard !isTornDown else { return } + onOutcome(EmbeddedBlockWire.outcomeEmpty as NSString, nil) } public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView, reason: MindboxEmbeddedBlockFailReason) { - onOutcome?(EmbeddedBlockWire.outcomeFail as NSString, reason.rawValue as NSString) + guard !isTornDown else { return } + onOutcome(EmbeddedBlockWire.outcomeFail as NSString, reason.rawValue as NSString) } } diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm index aa4043e..0204815 100644 --- a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm +++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm @@ -39,12 +39,6 @@ @implementation MindboxEmbeddedBlockViewComponentView { BOOL _hasPlaceholder; BOOL _hasErrorView; BOOL _isHostVisible; - - NSString *_pendingAppearance; - BOOL _pendingAppearanceAnimated; - NSInteger _pendingAppearanceRevealDurationMs; - NSString *_pendingOutcome; - NSString *_pendingOutcomeReason; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -107,56 +101,40 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask return; } - _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:@(_placeSystemName.c_str()) - height:_blockHeight - timeoutMs:_timeoutMs - loadingStrategy:@(_loadingStrategy.c_str()) - animatesReveal:_animatesReveal]; + // The callbacks go into the host's initializer: the block reports its first look the moment the + // host subscribes to it, and the event emitter is already in place here — Fabric sets it before + // `finalizeUpdates:` on every mount — so that first report reaches JS. + __weak MindboxEmbeddedBlockViewComponentView *weakSelf = self; + _host = [[MindboxEmbeddedBlockHost alloc] + initWithPlaceSystemName:@(_placeSystemName.c_str()) + height:_blockHeight + timeoutMs:_timeoutMs + loadingStrategy:@(_loadingStrategy.c_str()) + animatesReveal:_animatesReveal + onAppearance:^(NSString *appearance, BOOL animated, NSInteger revealDurationMs) { + [weakSelf emitAppearance:appearance animated:animated revealDurationMs:revealDurationMs]; + } + onOutcome:^(NSString *outcome, NSString *_Nullable reason) { + [weakSelf emitOutcome:outcome reason:reason]; + }]; [_host setStandInsWithHasPlaceholder:_hasPlaceholder hasErrorView:_hasErrorView]; [_host setHostVisible:_isHostVisible]; - __weak MindboxEmbeddedBlockViewComponentView *weakSelf = self; - _host.onAppearance = ^(NSString *appearance, BOOL animated, NSInteger revealDurationMs) { - [weakSelf emitAppearance:appearance animated:animated revealDurationMs:revealDurationMs]; - }; - _host.onOutcome = ^(NSString *outcome, NSString *_Nullable reason) { - [weakSelf emitOutcome:outcome reason:reason]; - }; - self.contentView = _host.view; } -- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter -{ - [super updateEventEmitter:eventEmitter]; - - if (_pendingAppearance != nil) { - NSString *appearance = _pendingAppearance; - _pendingAppearance = nil; - [self emitAppearance:appearance animated:_pendingAppearanceAnimated revealDurationMs:_pendingAppearanceRevealDurationMs]; - } - - if (_pendingOutcome != nil) { - NSString *outcome = _pendingOutcome; - NSString *reason = _pendingOutcomeReason; - _pendingOutcome = nil; - _pendingOutcomeReason = nil; - [self emitOutcome:outcome reason:reason]; - } -} - - (void)invalidate { [self dropHost]; _placeSystemName = ""; } +// The emitter is set before the host exists and lives as long as the view does, so the guards +// below state an invariant rather than wait for anything: a report with no emitter has nobody to +// go to and is dropped. - (void)emitAppearance:(NSString *)appearance animated:(BOOL)animated revealDurationMs:(NSInteger)revealDurationMs { if (!_eventEmitter) { - _pendingAppearance = appearance; - _pendingAppearanceAnimated = animated; - _pendingAppearanceRevealDurationMs = revealDurationMs; return; } @@ -169,8 +147,6 @@ - (void)emitAppearance:(NSString *)appearance animated:(BOOL)animated revealDura - (void)emitOutcome:(NSString *)outcome reason:(NSString *_Nullable)reason { if (!_eventEmitter) { - _pendingOutcome = outcome; - _pendingOutcomeReason = reason; return; } @@ -189,9 +165,6 @@ - (void)dropHost [_host tearDown]; self.contentView = nil; _host = nil; - _pendingAppearance = nil; - _pendingOutcome = nil; - _pendingOutcomeReason = nil; } @end @@ -295,33 +268,36 @@ - (void)buildHostIfPossible // The strategy and the animation flag are taken once as well: the native block takes them only // through its initializer. - _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:_builtPlaceSystemName - height:_blockHeight - timeoutMs:_builtTimeoutMs - loadingStrategy:_loadingStrategy ?: @"" - animatesReveal:_animatesReveal]; + // The callbacks go into the host's initializer, as under Fabric: the block reports its first + // look the moment the host subscribes to it. The event blocks are props, set before + // `didSetProps:` and before any layout pass, so they are in place whichever of the two builds. + __weak MindboxEmbeddedBlockPaperView *weakSelf = self; + _host = [[MindboxEmbeddedBlockHost alloc] + initWithPlaceSystemName:_builtPlaceSystemName + height:_blockHeight + timeoutMs:_builtTimeoutMs + loadingStrategy:_loadingStrategy ?: @"" + animatesReveal:_animatesReveal + onAppearance:^(NSString *appearance, BOOL animated, NSInteger revealDurationMs) { + MindboxEmbeddedBlockPaperView *strongSelf = weakSelf; + if (strongSelf.onAppearanceChange != nil) { + strongSelf.onAppearanceChange(@{ + @"appearance" : appearance, + @"animated" : @(animated), + @"revealDurationMs" : @(animated ? revealDurationMs : 0), + }); + } + } + onOutcome:^(NSString *outcome, NSString *_Nullable reason) { + MindboxEmbeddedBlockPaperView *strongSelf = weakSelf; + if (strongSelf.onBlockOutcome != nil) { + // The same shape as under Fabric: every field present, a missing reason is empty. + strongSelf.onBlockOutcome(@{@"outcome" : outcome, @"reason" : reason ?: @""}); + } + }]; [_host setStandInsWithHasPlaceholder:_hasPlaceholder hasErrorView:_hasErrorView]; [_host setHostVisible:_hostVisible]; - __weak MindboxEmbeddedBlockPaperView *weakSelf = self; - _host.onAppearance = ^(NSString *appearance, BOOL animated, NSInteger revealDurationMs) { - MindboxEmbeddedBlockPaperView *strongSelf = weakSelf; - if (strongSelf.onAppearanceChange != nil) { - strongSelf.onAppearanceChange(@{ - @"appearance" : appearance, - @"animated" : @(animated), - @"revealDurationMs" : @(animated ? revealDurationMs : 0), - }); - } - }; - _host.onOutcome = ^(NSString *outcome, NSString *_Nullable reason) { - MindboxEmbeddedBlockPaperView *strongSelf = weakSelf; - if (strongSelf.onBlockOutcome != nil) { - // The same shape as under Fabric: every field present, a missing reason is empty. - strongSelf.onBlockOutcome(@{@"outcome" : outcome, @"reason" : reason ?: @""}); - } - }; - UIView *blockView = _host.view; blockView.frame = self.bounds; blockView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; From 23506b2c827e617ebc7ed37b4a08089ee55e8a56 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 5 Oct 2026 11:26:59 +0500 Subject: [PATCH 06/10] MOBILE-557: Keep every appearance and outcome event of an Android block out of coalescing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt index aaa2c47..53f41bd 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt @@ -18,6 +18,11 @@ internal class AppearanceChangeEvent( ) : Event(surfaceId, viewTag) { override fun getEventName(): String = EVENT_NAME + // React Native coalesces same-named events of one view that are still queued when a frame is + // dispatched, keeping the last. The JS side reads every change — a reveal is content arriving + // where a placeholder or nothing stood — so none may be folded into the next. + override fun canCoalesce(): Boolean = false + override fun getEventData(): WritableMap = Arguments.createMap().apply { putString("appearance", appearance) putBoolean("animated", isRevealAnimated) @@ -41,6 +46,9 @@ internal class BlockOutcomeEvent( ) : Event(surfaceId, viewTag) { override fun getEventName(): String = EVENT_NAME + // Every outcome is promised to the host once it changes; two in one frame must both arrive. + override fun canCoalesce(): Boolean = false + override fun getEventData(): WritableMap = Arguments.createMap().apply { putString("outcome", outcome) putString("reason", reason.orEmpty()) From ac4b92aef8ca3dc8fb0407b95ffb3636fb1e5bdb Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 6 Oct 2026 02:35:30 +0500 Subject: [PATCH 07/10] MOBILE-557: Fade a host overlay out above the content it is replaced by, on the native curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- src/MindboxEmbeddedBlock.tsx | 94 +++++++++++++---- src/__tests__/MindboxEmbeddedBlock.test.tsx | 107 +++++++++++++++++++- 2 files changed, 178 insertions(+), 23 deletions(-) diff --git a/src/MindboxEmbeddedBlock.tsx b/src/MindboxEmbeddedBlock.tsx index 9ad1a31..ba5b0a5 100644 --- a/src/MindboxEmbeddedBlock.tsx +++ b/src/MindboxEmbeddedBlock.tsx @@ -33,6 +33,15 @@ const OUTCOMES: Array = ['load', 'empty', 'fail'] /** iOS and Android have the native block; nothing else does. */ const IS_SUPPORTED = Platform.OS === 'ios' || Platform.OS === 'android' +/** A host overlay drawn above the native block: the host's placeholder or its error screen. */ +type Overlay = 'placeholder' | 'error' + +/** + * The curve of the SDK's reveal on each platform, so the slot and the overlay move with the native + * fade: Material's standard curve on Android, ease-in-out on iOS. + */ +const REVEAL_EASING = Platform.OS === 'android' ? Easing.bezier(0.4, 0, 0.2, 1) : Easing.inOut(Easing.ease) + export type MindboxEmbeddedBlockProps = { /** * The name of the place from the admin panel. A different name is a different block, built from @@ -41,8 +50,8 @@ export type MindboxEmbeddedBlockProps = { * Passed down as given. Whitespace around the name is not part of it: the native blocks ignore * it, so a name pasted from the admin panel with a stray space still finds its place. The name * itself is matched the way the native SDK matches it. A name that is empty — or nothing but - * spaces — is no name at all, so the place resolves to nothing, collapses and reports [onFail]; - * the component warns about that. + * spaces — is no name at all, so the place resolves to nothing: the block collapses and reports + * [onEmpty], as for any place with nothing behind it. The component warns about that. */ placeSystemName: string @@ -82,6 +91,10 @@ export type MindboxEmbeddedBlockProps = { * waits hidden never flashes at the price of the layout growing when content arrives. A place * that always has a campaign behind it is worth an explicit `placeholder`. * + * A block that waits hidden — `hidden`, and `automatic` at a place that has not shown content + * yet — draws neither [placeholder] nor [error] until its content has been shown once: a failure + * keeps it collapsed, and only [onFail] tells. + * * Fixed when the block is created, as [timeoutMs] is: a new value on a live block is ignored with * a warning. Remount the component (give it a new `key`) to build a block anew. */ @@ -98,7 +111,11 @@ export type MindboxEmbeddedBlockProps = { /** * Drawn instead of the SDK shimmer while the block is loading. Fills the whole place, as the native - * placeholder does. + * placeholder does. Not drawn by a block that waits hidden — see [loadingStrategy] — until its + * content has been shown once. + * + * When the content arrives with the SDK's reveal, the placeholder fades out over it rather than + * vanishing a frame before the content is opaque. */ placeholder?: React.ReactNode @@ -106,12 +123,17 @@ export type MindboxEmbeddedBlockProps = { * Drawn instead of collapsing when the block cannot be shown. * * Applies only to failures: an empty place — one with nothing behind its place system name — always - * collapses, so a host cannot fill the space of a block that was never meant to be there. + * collapses, so a host cannot fill the space of a block that was never meant to be there. Nor is it + * drawn by a block that waits hidden — see [loadingStrategy] — which never took the space: with the + * default strategy, a place that has not shown content on this device yet simply stays collapsed + * on a failure, and only [onFail] tells. */ error?: React.ReactNode /** - * The content is shown: the block has taken its height and is visible. + * The content is shown: the block has taken its height and is visible. A block that waited hidden + * is only starting to grow from zero at this moment — a host that measures the block here, or + * scrolls to it, sees it still at almost no height until the reveal is over. * * Delivered once per outcome, not once per lifetime: the same outcome is never repeated, and an * outcome that actually changed — a place that filled up after a failure — is delivered again. @@ -132,8 +154,9 @@ export type MindboxEmbeddedBlockProps = { /** * The block could not be shown: the SDK had no config or never answered, the page could not be * loaded, the content is malformed or the SDK hit an internal error. The block collapses, or keeps - * its height and draws [error] when one is given. An empty place is not a failure and arrives in - * [onEmpty] instead. + * its height and draws [error] when one is given — unless it was waiting hidden, see + * [loadingStrategy]: a block that never took its space stays collapsed whatever [error] says. An + * empty place is not a failure and arrives in [onEmpty] instead. * * The reason is for logs and analytics, not for branching: whatever it is, the block has already * collapsed or switched to [error]. Compare it with the constants of @@ -209,7 +232,7 @@ const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEm // SDK trims the name before it resolves by it, so a padded name finds its place either way. useEffect(() => { if (placeSystemName.trim().length === 0) { - console.warn('[MindboxEmbeddedBlock] A block was created without a place system name: there is nothing to resolve by it, so the place collapses and reports onFail.') + console.warn('[MindboxEmbeddedBlock] A block was created without a place system name: there is nothing to resolve by it, so the place collapses and reports onEmpty.') } if (blockHeight <= 0) { console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" was created with height ${height}: it reserves no space, so nothing loads and no outcome is reported.`) @@ -273,27 +296,58 @@ const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEm /** The growth of a block that waited hidden: 0 to 1 over the SDK's reveal, 1 at rest. */ const reveal = useRef(new Animated.Value(1)).current + /** The opacity of a host overlay the content is replacing: 1 to 0 over the SDK's reveal. */ + const overlayFade = useRef(new Animated.Value(1)).current + /** The overlay still fading out above the content, if any. */ + const [fadingOverlay, setFadingOverlay] = useState(null) + /** Tells a fade that ran its course from one that was interrupted and restarted. */ + const fadeGeneration = useRef(0) const shownAppearance = useRef(appearance) /** - * Takes the block to [next]. The reveal of content into a slot that was closed is the one change - * that is animated, and only when the native block says so — it owns that decision, gates - * included — for as long as it says; everything else lands at once. + * Takes the block to [next]. The arrival of content is the one change that is animated, and only + * when the native block says so — it owns that decision, gates included — for as long as it + * says: a slot that was closed grows, and a host overlay the content replaces fades out above + * it. Everything else lands at once. */ const show = useCallback( (next: Appearance, revealDurationMs?: number) => { - const opens = shownAppearance.current === 'collapsed' && next === 'content' + const previous = shownAppearance.current + const duration = revealDurationMs ?? 0 + const reveals = next === 'content' && duration > 0 shownAppearance.current = next setAppearance(next) - if (opens && revealDurationMs != null && revealDurationMs > 0) { + + if (reveals && previous === 'collapsed') { reveal.setValue(0) - Animated.timing(reveal, { toValue: 1, duration: revealDurationMs, easing: Easing.inOut(Easing.ease), useNativeDriver: false }).start() + Animated.timing(reveal, { toValue: 1, duration, easing: REVEAL_EASING, useNativeDriver: false }).start() } else { reveal.stopAnimation() reveal.setValue(1) } + + // The native block fades its content in from transparent, and the host's stand-in under it + // is clear — so a placeholder or an error screen taken away in the same frame would leave the + // screen's background showing through the fade. SwiftUI and Compose fade their stand-ins out + // under the content for the same reason; here the overlay lies above the native view, so it + // fades out over the same duration instead. Any other change takes it away at once. + fadeGeneration.current += 1 + const generation = fadeGeneration.current + overlayFade.stopAnimation() + if (reveals && (previous === 'placeholder' || previous === 'error')) { + setFadingOverlay(previous) + overlayFade.setValue(1) + Animated.timing(overlayFade, { toValue: 0, duration, easing: REVEAL_EASING, useNativeDriver: false }).start(({ finished }) => { + if (finished && fadeGeneration.current === generation) { + setFadingOverlay(null) + } + }) + } else { + setFadingOverlay(null) + overlayFade.setValue(1) + } }, - [reveal] + [reveal, overlayFade] ) const handleAppearanceChange = useCallback( @@ -316,7 +370,10 @@ const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEm [deliver] ) - const overlay = appearance === 'placeholder' ? placeholder : appearance === 'error' ? error : null + // The overlay of the current look, or the one still fading out above the content. + const shownOverlay: Overlay | null = appearance === 'placeholder' || appearance === 'error' ? appearance : fadingOverlay + const overlay = shownOverlay === 'placeholder' ? placeholder : shownOverlay === 'error' ? error : null + const isOverlayFading = shownOverlay != null && shownOverlay !== appearance // The height the layout is given: nothing for a collapsed block, the block's height otherwise — // and, while a block that waited hidden is revealed, the part of it the growth has reached. @@ -340,9 +397,10 @@ const Block = ({ placeSystemName, height, timeoutMs, loadingStrategy = MindboxEm {IS_SUPPORTED ? : null} {overlay != null ? ( - + // An overlay on its way out is a picture, not a surface: touches go through to the content. + {overlay} - + ) : null} diff --git a/src/__tests__/MindboxEmbeddedBlock.test.tsx b/src/__tests__/MindboxEmbeddedBlock.test.tsx index c40f720..0d4d4eb 100644 --- a/src/__tests__/MindboxEmbeddedBlock.test.tsx +++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx @@ -114,16 +114,20 @@ describe('MindboxEmbeddedBlock', () => { warn.mockRestore() }) - it('hands the failure of a nameless place to the host and gives the space back', () => { + it('hands a nameless place to the host as an empty one and gives the space back', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const onEmpty = jest.fn() const onFail = jest.fn() - const renderer = render(placeholderBlock({ placeSystemName: '', onFail })) + const renderer = render(placeholderBlock({ placeSystemName: '', onEmpty, onFail })) + expect(warn.mock.calls[0][0]).toContain('reports onEmpty') + + // What the native blocks report for a name of nothing: a place with nothing behind it. reportAppearance(renderer, 'collapsed') - reportOutcome(renderer, 'fail', 'internalError') + reportOutcome(renderer, 'empty') - expect(onFail).toHaveBeenCalledTimes(1) - expect(onFail).toHaveBeenCalledWith('internalError') + expect(onEmpty).toHaveBeenCalledTimes(1) + expect(onFail).not.toHaveBeenCalled() expect(frameHeight(renderer)).toBe(0) warn.mockRestore() }) @@ -466,6 +470,99 @@ describe('MindboxEmbeddedBlock', () => { expect(frameHeight(renderer)).toBe(0) }) + + it.each(['error', 'placeholder'])('takes the full height at once when %s arrives in the middle of a reveal', (appearance) => { + const renderer = render(loading} error={broken} />) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + tick(125) + const midway = frameHeight(renderer) + expect(midway).toBeGreaterThan(0) + expect(midway).toBeLessThan(104) + + reportAppearance(renderer, appearance) + + expect(frameHeight(renderer)).toBe(104) + + // The old growth is stopped, not left to finish on its own schedule. + tick(60) + + expect(frameHeight(renderer)).toBe(104) + }) + + describe('the host overlay under the reveal', () => { + /** The host's overlay layer: the view that holds the placeholder or the error screen. */ + const overlayHost = (renderer: ReactTestRenderer) => renderer.root.findAll((node) => node.type === View && (node.props.pointerEvents === 'box-none' || node.props.pointerEvents === 'none'))[0] + + const overlayOpacity = (renderer: ReactTestRenderer) => StyleSheet.flatten(overlayHost(renderer).props.style).opacity + + it('keeps the host placeholder above the arriving content and fades it out over the reveal', () => { + const renderer = render(placeholderBlock({ placeholder: loading })) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + + expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') + expect(overlayHost(renderer).props.pointerEvents).toBe('none') + expect(overlayOpacity(renderer)).toBe(1) + + tick(125) + + expect(overlayOpacity(renderer)).toBeGreaterThan(0.2) + expect(overlayOpacity(renderer)).toBeLessThan(0.8) + + tick(125) + + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + }) + + it('fades the host error screen out the same way when content replaces it', () => { + const renderer = render(placeholderBlock({ error: broken })) + + reportAppearance(renderer, 'error') + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + + expect(renderer.root.findByType(asType(Text)).props.children).toBe('broken') + expect(overlayHost(renderer).props.pointerEvents).toBe('none') + + tick(250) + + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + }) + + it('takes the placeholder away at once when the content is not a reveal', () => { + const renderer = render(placeholderBlock({ placeholder: loading })) + + reportAppearance(renderer, 'content') + + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + }) + + it('takes a fading overlay away at once when the look changes again', () => { + const renderer = render(placeholderBlock({ placeholder: loading })) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + tick(125) + reportAppearance(renderer, 'collapsed') + + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + + tick(250) + + expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0) + }) + + it('shows the placeholder again, opaque and touchable, when a reload follows the reveal', () => { + const renderer = render(placeholderBlock({ placeholder: loading })) + + reportAppearance(renderer, 'content', { animated: true, revealDurationMs: 250 }) + tick(250) + reportAppearance(renderer, 'placeholder') + + expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading') + expect(overlayHost(renderer).props.pointerEvents).toBe('box-none') + expect(overlayOpacity(renderer)).toBeUndefined() + }) + }) }) }) From 927deea7b92f99358e95680fa07e1b500a51e3e4 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 6 Oct 2026 02:35:32 +0500 Subject: [PATCH 08/10] MOBILE-557: Say that a block waiting hidden draws no placeholder or error, and where the layout moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 23 ++++++++++++++++------ src/MindboxEmbeddedBlockFailReason.ts | 4 ++-- src/MindboxEmbeddedBlockLoadingStrategy.ts | 8 ++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b1047b7..a785507 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ import { MindboxEmbeddedBlock } from 'mindbox-sdk'; The outcome arrives through three callbacks, the same three as in SwiftUI, Compose and Flutter: `onLoad` when the content is shown, `onEmpty` when there is nothing to show at the place, and `onFail` with a `MindboxEmbeddedBlockFailReason` when the block could not be shown. An empty place -is a normal outcome, not a breakage, and comes with no reason. A failure's reason — `networkError` +is a normal outcome, not a breakage, and comes with no reason; a block whose place system name is +empty is such a place too. A failure's reason — `networkError` or `internalError` — is for logs and analytics, not for branching: by the time it arrives the block has already collapsed or switched to `error`. A later SDK may add reasons, so keep a fallback when matching. @@ -89,13 +90,23 @@ a screen nobody is looking at. What the block shows until the SDK has decided what goes into it is `loadingStrategy`, the same three choices as in SwiftUI, Compose and Flutter. `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, so -the layout does not jump where content is expected and does not flash where it is not. -`placeholder` takes the space up front, worth naming for a place that always has a campaign behind -it. `hidden` never takes it until the content is shown: no placeholder, and no `error` on a failure. +until the place has shown content once on this device and puts a placeholder there from then on: +nothing flashes where no content is expected, and where it is expected the space is taken as soon +as the native side has read the place's memory — a frame after the block is mounted, so what stands +below moves down by the block's height once. `placeholder` takes the space from the first frame, +worth naming for a place that always has a campaign behind it. `hidden` never takes it until the +content is shown. + +A block that waits hidden — `hidden`, and `automatic` at a place that has not shown content yet — +draws neither `placeholder` nor `error` until its content has been shown once: a failure keeps it +collapsed, and only `onFail` tells. With the default strategy that is every place on a fresh +install, so a host that counts on its `error` screen names `loadingStrategy="placeholder"`. + The content is revealed with the SDK's own animation — it fades in, and a block that started hidden grows to its height — unless `animatesReveal` is off; the system's reduced-motion setting turns it -off as well. Turn it off to animate the block's container yourself in `onLoad`. +off as well. Turn it off to animate the block's container yourself in `onLoad` — keeping in mind +that a block that waited hidden is only starting to grow from zero at that moment. A host +`placeholder` the content replaces fades out above it over the same reveal. ```tsx Date: Tue, 6 Oct 2026 02:35:34 +0500 Subject: [PATCH 09/10] MOBILE-557: Name what breaks for a host on 2.16.0-rc, under the header 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. --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e5078..1d1537b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## Unreleased +## [Unreleased] ### Changes +- **Breaking since `2.16.0-rc`:** `onFail` of `MindboxEmbeddedBlock` reports failures only; a place with nothing to show arrives in the new `onEmpty`. A host that hid its section in `onFail` — as the previous README showed — moves that to `onEmpty`. +- **Breaking since `2.16.0-rc`:** the `MindboxEmbeddedBlockFailure` type is removed; `onFail` receives a `MindboxEmbeddedBlockFailReason` string instead of an empty object. +- **Breaking since `2.16.0-rc`:** `MindboxEmbeddedBlock` starts by `loadingStrategy="automatic"` — hidden until the place has shown content once on this device — instead of always showing a placeholder; `loadingStrategy="placeholder"` restores the previous look. - Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel, a native component for both the old and the new architecture. - `MindboxEmbeddedBlock` reports three outcomes, as the native SwiftUI and Compose blocks do: `onLoad`, `onEmpty` and `onFail` with a `MindboxEmbeddedBlockFailReason` (`networkError` or `internalError`). - `MindboxEmbeddedBlock` takes a `loadingStrategy` — `automatic` (the default: hidden until the place has shown content once on this device, a placeholder from then on), `placeholder` or `hidden` — and `animatesReveal`, as the native SwiftUI and Compose blocks do. A block that waited hidden grows to its height with the SDK's reveal when its content arrives. From b5a51fce86008f74db8429e9fa92b3624f9e7b19 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 6 Oct 2026 02:35:35 +0500 Subject: [PATCH 10/10] MOBILE-557: Log an unknown loading strategy on Android once the place 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. --- .../embedded/MindboxEmbeddedBlockHostView.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt index fc20a2f..48fc5b5 100644 --- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt +++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt @@ -28,6 +28,8 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont private var placeSystemName: String? = null private var timeoutMs: Long? = null private var loadingStrategy: MindboxEmbeddedBlockLoadingStrategy = MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC + /** A strategy word this SDK did not know, kept to be logged once the block is built and the place is known. */ + private var unknownLoadingStrategyWord: String? = null private var animatesReveal: Boolean = true private var hostVisible: Boolean = true private var hasPlaceholder: Boolean = false @@ -89,19 +91,16 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont } // Fixed at creation, as the timeout is: the JS side warns the host about a later value rather - // than applying it, and the native block takes both only through its constructor. + // than applying it, and the native block takes both only through its constructor. A word this + // SDK does not know is read as `automatic` and logged when the block is built — Fabric sets the + // props in no fixed order, and here the place name may not have arrived yet. fun setLoadingStrategy(word: String?) { if (blockView != null) { return } val strategy = EmbeddedBlockWire.loadingStrategyOf(word) - if (strategy == null) { - Mindbox.writeLog( - message = "[EmbeddedBlock] A React Native block for place '$placeSystemName' was given a loading strategy this SDK does not know ('$word') and starts as automatic", - logLevel = Level.ERROR, - ) - } + unknownLoadingStrategyWord = if (strategy == null) word else null loadingStrategy = strategy ?: MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC } @@ -160,6 +159,12 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont logLevel = Level.ERROR, ) } + unknownLoadingStrategyWord?.let { word -> + Mindbox.writeLog( + message = "[EmbeddedBlock] A React Native block for place '$place' was given a loading strategy this SDK does not know ('$word') and starts as automatic", + logLevel = Level.ERROR, + ) + } val block = MindboxEmbeddedBlockView( context = context,