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'