diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6171e58..1d1537b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## [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.
+
## [2.16.0-rc] - 2026-09-11
### Changes
diff --git a/README.md b/README.md
index 4f101aa..a785507 100644
--- a/README.md
+++ b/README.md
@@ -47,10 +47,19 @@ 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 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.
+
+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}`)}
/>
```
@@ -78,10 +88,43 @@ 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:
+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` — 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
+
+```
+
`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
+everywhere.
### Push Notifications
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 a4a04e0..53f41bd 100644
--- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt
+++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt
@@ -4,15 +4,29 @@ 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
+ // 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)
+ putInt("revealDurationMs", if (isRevealAnimated) revealDurationMs else 0)
}
companion object {
@@ -20,22 +34,27 @@ 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()
+ // Every outcome is promised to the host once it changes; two in one frame must both arrive.
+ override fun canCoalesce(): Boolean = false
- 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..48fc5b5 100644
--- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt
+++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt
@@ -10,20 +10,27 @@ 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
- 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
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
private var hasErrorView: Boolean = false
@@ -83,6 +90,26 @@ 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. 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)
+ unknownLoadingStrategyWord = if (strategy == null) word else null
+ loadingStrategy = strategy ?: MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC
+ }
+
+ fun setAnimatesReveal(animatesReveal: Boolean) {
+ if (blockView == null) {
+ this.animatesReveal = animatesReveal
+ }
+ }
+
fun setHostVisible(isHostVisible: Boolean) {
if (hostVisible == isHostVisible) {
return
@@ -132,8 +159,20 @@ 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, place, timeoutMs)
+ val block = MindboxEmbeddedBlockView(
+ context = context,
+ placeSystemName = place,
+ timeoutMs = timeoutMs,
+ loadingStrategy = loadingStrategy,
+ animatesReveal = animatesReveal,
+ )
blockView = block
syncStandIns()
@@ -141,15 +180,29 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont
block.setListener(
object : MindboxEmbeddedBlockListener {
override fun onLoad(view: MindboxEmbeddedBlockView) {
- onOutcome?.invoke(OUTCOME_LOAD)
+ onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_LOAD, null)
}
- override fun onFail(view: MindboxEmbeddedBlockView) {
- onOutcome?.invoke(OUTCOME_FAIL)
+ override fun onEmpty(view: MindboxEmbeddedBlockView) {
+ onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_EMPTY, null)
+ }
+
+ override fun onFail(view: MindboxEmbeddedBlockView, reason: MindboxEmbeddedBlockFailReason) {
+ 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))
}
@@ -202,16 +255,4 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont
isClickable = false
isFocusable = false
}
-
- private companion object {
- const val OUTCOME_LOAD = "load"
- 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 c205157..45db945 100644
--- a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt
+++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt
@@ -25,13 +25,11 @@ 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 ->
- 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) }
}
}
@@ -60,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)
}
@@ -74,8 +80,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 +94,5 @@ internal class MindboxEmbeddedBlockViewManager :
internal companion object {
const val NAME = "MindboxEmbeddedBlockView"
- const val OUTCOME_LOAD = "load"
}
}
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 5161f54..dc3bd71 100644
--- a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift
+++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift
@@ -4,18 +4,43 @@ 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.
+ /// 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
- @objc public var onOutcome: ((NSString) -> Void)?
+ /// The outcome word and, for a failure, the reason's raw value.
+ private let onOutcome: (NSString, NSString?) -> Void
@objc public var view: UIView { blockView }
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.
+ ///
+ /// 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,
+ onAppearance: @escaping (NSString, Bool, Int) -> Void,
+ onOutcome: @escaping (NSString, NSString?) -> Void) {
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)
+ self.onAppearance = onAppearance
+ self.onOutcome = onOutcome
super.init()
if placeSystemName.isEmpty {
@@ -23,10 +48,24 @@ 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.
+ // 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
- self?.onAppearance?(Self.name(of: appearance) as NSString)
+ guard let self, !self.isTornDown else { return }
+ self.onAppearance(EmbeddedBlockWire.name(of: appearance) as NSString,
+ self.blockView.isRevealAnimated,
+ EmbeddedBlockWire.revealDurationMs)
}
}
@@ -56,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()
@@ -69,23 +106,24 @@ 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")
+ guard !isTornDown else { return }
+ onOutcome(EmbeddedBlockWire.outcomeLoad as NSString, nil)
}
- public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) {
- onOutcome?("fail")
+ public func mindboxEmbeddedBlockViewDidBecomeEmpty(_ blockView: MindboxEmbeddedBlockView) {
+ guard !isTornDown else { return }
+ onOutcome(EmbeddedBlockWire.outcomeEmpty as NSString, nil)
+ }
+
+ public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView,
+ reason: MindboxEmbeddedBlockFailReason) {
+ 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 4340088..0204815 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
@@ -37,12 +34,11 @@ @implementation MindboxEmbeddedBlockViewComponentView {
std::string _placeSystemName;
CGFloat _blockHeight;
double _timeoutMs;
+ std::string _loadingStrategy;
+ BOOL _animatesReveal;
BOOL _hasPlaceholder;
BOOL _hasErrorView;
BOOL _isHostVisible;
-
- NSString *_pendingAppearance;
- NSString *_pendingOutcome;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider
@@ -61,6 +57,7 @@ - (instancetype)initWithFrame:(CGRect)frame
static const auto defaultProps = std::make_shared();
_props = defaultProps;
_isHostVisible = YES;
+ _animatesReveal = YES;
}
return self;
@@ -82,6 +79,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,70 +101,59 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
return;
}
- _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:@(_placeSystemName.c_str())
- height:_blockHeight
- timeoutMs:_timeoutMs];
+ // 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) {
- [weakSelf emitAppearance:appearance];
- };
- _host.onOutcome = ^(NSString *outcome) {
- [weakSelf emitOutcome:outcome];
- };
-
self.contentView = _host.view;
}
-- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
-{
- [super updateEventEmitter:eventEmitter];
-
- if (_pendingAppearance != nil) {
- NSString *appearance = _pendingAppearance;
- _pendingAppearance = nil;
- [self emitAppearance:appearance];
- }
-
- if (_pendingOutcome != nil) {
- NSString *outcome = _pendingOutcome;
- _pendingOutcome = nil;
- [self emitOutcome:outcome];
- }
-}
-
- (void)invalidate
{
[self dropHost];
_placeSystemName = "";
}
-- (void)emitAppearance:(NSString *)appearance
+// 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;
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
+- (void)emitOutcome:(NSString *)outcome reason:(NSString *_Nullable)reason
{
if (!_eventEmitter) {
- _pendingOutcome = outcome;
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
@@ -177,8 +165,6 @@ - (void)dropHost
[_host tearDown];
self.contentView = nil;
_host = nil;
- _pendingAppearance = nil;
- _pendingOutcome = nil;
}
@end
@@ -210,13 +196,14 @@ @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;
@property (nonatomic, copy) RCTDirectEventBlock onAppearanceChange;
-@property (nonatomic, copy) RCTDirectEventBlock onBlockLoad;
-@property (nonatomic, copy) RCTDirectEventBlock onBlockFail;
+@property (nonatomic, copy) RCTDirectEventBlock onBlockOutcome;
@end
@@ -230,6 +217,7 @@ - (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
_hostVisible = YES;
+ _animatesReveal = YES;
}
return self;
@@ -278,30 +266,38 @@ - (void)buildHostIfPossible
// JS side warns the host about a new value rather than applying it.
_builtTimeoutMs = _timeoutMs;
- _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:_builtPlaceSystemName
- height:_blockHeight
- timeoutMs:_builtTimeoutMs];
+ // The strategy and the animation flag are taken once as well: the native block takes them only
+ // through its initializer.
+ // 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) {
- MindboxEmbeddedBlockPaperView *strongSelf = weakSelf;
- if (strongSelf.onAppearanceChange != nil) {
- strongSelf.onAppearanceChange(@{@"appearance" : appearance});
- }
- };
- _host.onOutcome = ^(NSString *outcome) {
- MindboxEmbeddedBlockPaperView *strongSelf = weakSelf;
- if ([outcome isEqualToString:MindboxEmbeddedBlockOutcomeLoad]) {
- if (strongSelf.onBlockLoad != nil) {
- strongSelf.onBlockLoad(@{});
- }
- } else if (strongSelf.onBlockFail != nil) {
- strongSelf.onBlockFail(@{});
- }
- };
-
UIView *blockView = _host.view;
blockView.frame = self.bounds;
blockView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
@@ -341,13 +337,14 @@ - (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)
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..ba5b0a5 100644
--- a/src/MindboxEmbeddedBlock.tsx
+++ b/src/MindboxEmbeddedBlock.tsx
@@ -1,14 +1,17 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react'
-import { 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'
/**
- * 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,28 +19,39 @@ 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']
+/** 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'
+
+/** A host overlay drawn above the native block: the host's placeholder or its error screen. */
+type Overlay = 'placeholder' | 'error'
+
/**
- * 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.
+ * 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.
*/
-export type MindboxEmbeddedBlockFailure = {}
+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
* 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: the block collapses and reports
+ * [onEmpty], as for any place with nothing behind it. The component warns about that.
*/
placeSystemName: string
@@ -68,9 +82,40 @@ 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`.
+ *
+ * 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.
+ */
+ 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.
+ * 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
@@ -78,18 +123,49 @@ 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 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.
+ * 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.
+ */
+ 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 — 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
+ * [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?: (failure: MindboxEmbeddedBlockFailure) => void
+ onFail?: (reason: MindboxEmbeddedBlockFailReason) => void
/**
* Whether the screen the block stands on is the one being looked at. `true` by default.
@@ -115,20 +191,36 @@ 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.
*
+ * 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.
+ *
* 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, 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
@@ -140,7 +232,7 @@ const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad,
// 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.`)
@@ -149,66 +241,194 @@ 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])
- 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 }
- const handleAppearanceChange = useCallback((event) => {
- const reported = event.nativeEvent.appearance
- if (APPEARANCES.includes(reported)) {
- setAppearance(reported as Appearance)
- }
- }, [])
+ /**
+ * 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 handleLoad = useCallback(() => {
- if (deliveredOutcome.current === 'load') {
+ const deliver = useCallback((outcome: Outcome, reason: string | null) => {
+ if (outcome === deliveredOutcome.current) {
return
}
- deliveredOutcome.current = 'load'
- onLoad?.()
- }, [onLoad])
+ 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
+ }
+ }, [])
- const handleFail = useCallback(() => {
- if (deliveredOutcome.current === 'fail') {
- return
+ // 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)
}
- deliveredOutcome.current = 'fail'
- onFail?.({})
- }, [onFail])
+ }, [deliver])
+
+ /** 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 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 previous = shownAppearance.current
+ const duration = revealDurationMs ?? 0
+ const reveals = next === 'content' && duration > 0
+ shownAppearance.current = next
+ setAppearance(next)
+
+ if (reveals && previous === 'collapsed') {
+ reveal.setValue(0)
+ 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, overlayFade]
+ )
+
+ const handleAppearanceChange = useCallback(
+ (event) => {
+ 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
+ if (OUTCOMES.includes(outcome)) {
+ deliver(outcome as Outcome, reason)
+ }
+ },
+ [deliver]
+ )
+
+ // 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
- 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 (
-
-
- {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 ? (
+ // An overlay on its way out is a picture, not a surface: touches go through to the content.
+
+ {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: 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')
+
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/MindboxEmbeddedBlockFailReason.ts b/src/MindboxEmbeddedBlockFailReason.ts
new file mode 100644
index 0000000..b471408
--- /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 — on iOS — 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/MindboxEmbeddedBlockLoadingStrategy.ts b/src/MindboxEmbeddedBlockLoadingStrategy.ts
new file mode 100644
index 0000000..e55e90a
--- /dev/null
+++ b/src/MindboxEmbeddedBlockLoadingStrategy.ts
@@ -0,0 +1,43 @@
+/**
+ * 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: nothing flashes where no content is expected, and where it is expected the
+ * space is taken as soon as the native side has read that memory — a frame after the block is
+ * mounted, not from the first frame.
+ *
+ * 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.
+ *
+ * At a place that has not shown content yet the block waits hidden, as under `hidden`: no
+ * placeholder, and no `error` on a failure — only `onFail` tells.
+ */
+ 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 35a1c68..a999cfc 100644
--- a/src/MindboxEmbeddedBlockNativeComponent.ts
+++ b/src/MindboxEmbeddedBlockNativeComponent.ts
@@ -1,9 +1,26 @@
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
+}>
+
+/**
+ * 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 {
@@ -13,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
@@ -21,9 +43,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..0d4d4eb 100644
--- a/src/__tests__/MindboxEmbeddedBlock.test.tsx
+++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx
@@ -4,6 +4,8 @@ import { act, create } from 'react-test-renderer'
import type { ReactTestRenderer } from 'react-test-renderer'
import { MindboxEmbeddedBlock } from '../MindboxEmbeddedBlock'
+import { MindboxEmbeddedBlockFailReason } from '../MindboxEmbeddedBlockFailReason'
+import { MindboxEmbeddedBlockLoadingStrategy } from '../MindboxEmbeddedBlockLoadingStrategy'
jest.mock('../MindboxEmbeddedBlockNativeComponent', () => {
const ReactActual = require('react')
@@ -32,20 +34,36 @@ 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 } })
})
}
+/** 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 } })
+ })
+}
+
+/** 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> = {}) =>
+
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)
@@ -55,19 +73,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', () => {
+ 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()
+ const renderer = render(placeholderBlock({ placeSystemName: ' stories ' }))
- update(renderer, )
+ update(renderer, placeholderBlock({ placeSystemName: ' stories ' }))
expect(warn).not.toHaveBeenCalled()
expect(nativeProps(renderer).placeSystemName).toBe(' stories ')
@@ -76,9 +95,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')
@@ -88,24 +107,27 @@ 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')
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()
+ 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')
- act(() => {
- nativeProps(renderer).onBlockFail()
- })
+ reportOutcome(renderer, 'empty')
- expect(onFail).toHaveBeenCalledTimes(1)
+ expect(onEmpty).toHaveBeenCalledTimes(1)
+ expect(onFail).not.toHaveBeenCalled()
expect(frameHeight(renderer)).toBe(0)
warn.mockRestore()
})
@@ -116,7 +138,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')
@@ -126,7 +148,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',
@@ -138,31 +160,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')
@@ -175,27 +198,114 @@ 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(placeholderBlock({ onLoad, onFail }))
+
+ 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(placeholderBlock({ onEmpty, onFail }))
+
+ reportAppearance(renderer, 'collapsed')
+ reportOutcome(renderer, 'empty')
+
+ expect(onEmpty).toHaveBeenCalledTimes(1)
+ expect(onEmpty).toHaveBeenCalledWith()
+ expect(onFail).not.toHaveBeenCalled()
+ expect(frameHeight(renderer)).toBe(0)
+ })
+
+ it('hands the failure reason to onFail as the native block spelled it', () => {
+ const onFail = jest.fn()
+ const renderer = render(placeholderBlock({ onFail }))
+
+ reportOutcome(renderer, 'fail', 'networkError')
- act(() => {
- nativeProps(renderer).onBlockFail()
+ expect(onFail).toHaveBeenCalledWith(MindboxEmbeddedBlockFailReason.networkError)
})
- expect(onFail).toHaveBeenCalledTimes(1)
+ it('passes a reason it does not know through as it is', () => {
+ const onFail = jest.fn()
+ const renderer = render(placeholderBlock({ onFail }))
+
+ 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(placeholderBlock({ onFail }))
+
+ 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(placeholderBlock({ onFail }))
+
+ 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(placeholderBlock({ onLoad: () => 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(placeholderBlock({ onLoad, onEmpty, onFail }))
+
+ 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(placeholderBlock({ onLoad: first }))
+
+ update(renderer, placeholderBlock({ onLoad: second }))
+ reportOutcome(renderer, 'load')
+
+ expect(first).not.toHaveBeenCalled()
+ expect(second).toHaveBeenCalledTimes(1)
+ })
})
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')
@@ -205,16 +315,266 @@ 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, placeholderBlock({ placeSystemName: 'banner', onLoad }))
+ reportOutcome(renderer, 'load')
+
+ expect(onLoad).toHaveBeenCalledTimes(2)
+ })
- act(() => {
- nativeProps(renderer).onBlockLoad()
+ 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 })
})
- update(renderer, )
- act(() => {
- nativeProps(renderer).onBlockLoad()
+
+ it('starts automatic and animated when the host says nothing', () => {
+ const renderer = render()
+
+ expect(nativeProps(renderer)).toMatchObject({ loadingStrategy: 'automatic', animatesReveal: true })
})
- expect(onLoad).toHaveBeenCalledTimes(2)
+ 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', () => {
+ const renderer = render(placeholderBlock({ placeholder: loading }))
+
+ expect(frameHeight(renderer)).toBe(104)
+ expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading')
+ })
+
+ 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)
+ })
+
+ 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)
+
+ 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 when the native block reports collapsed', () => {
+ const renderer = render()
+
+ reportAppearance(renderer, 'collapsed')
+
+ expect(frameHeight(renderer)).toBe(0)
+ })
+ })
+
+ 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)
+ })
+
+ 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()
+ })
+ })
+ })
+})
+
+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..422afce 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -496,4 +496,8 @@ export { InAppCallback, CopyPayloadInAppCallback, EmptyInAppCallback, UrlInAppCa
export { MindboxEmbeddedBlock } from './MindboxEmbeddedBlock'
-export type { MindboxEmbeddedBlockProps, MindboxEmbeddedBlockFailure } from './MindboxEmbeddedBlock'
+export type { MindboxEmbeddedBlockProps } from './MindboxEmbeddedBlock'
+
+export { MindboxEmbeddedBlockFailReason } from './MindboxEmbeddedBlockFailReason'
+
+export { MindboxEmbeddedBlockLoadingStrategy } from './MindboxEmbeddedBlockLoadingStrategy'