diff --git a/formula-android-tests/src/main/AndroidManifest.xml b/formula-android-tests/src/main/AndroidManifest.xml
index c48ff6712..3ae9bb658 100644
--- a/formula-android-tests/src/main/AndroidManifest.xml
+++ b/formula-android-tests/src/main/AndroidManifest.xml
@@ -34,5 +34,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt
index 2b097bd86..29c8569c5 100644
--- a/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt
+++ b/formula-android-tests/src/test/java/com/instacart/formula/ActivityLifecycleEventTest.kt
@@ -57,4 +57,12 @@ class ActivityLifecycleEventTest {
val expected = listOf(INITIALIZED) + lifecycle + lifecycle
assertThat(events).containsExactlyElementsIn(expected).inOrder()
}
+
+ @Test fun `calling onPreCreate() twice will throw an exception`() {
+ val activity = scenario.activity()
+ val result = runCatching { FormulaAndroid.onPreCreate(activity, null) }
+ assertThat(result.exceptionOrNull()).hasMessageThat().contains(
+ "Activity TestActivity was already initialized. Did you call FormulaAndroid.onPreCreate() twice?"
+ )
+ }
}
diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt
index e7e1152ad..7669b1274 100644
--- a/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt
+++ b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt
@@ -15,6 +15,7 @@ import com.instacart.formula.android.BackCallback
import com.instacart.formula.android.FormulaFragment
import com.instacart.formula.android.FragmentEnvironment
import com.instacart.formula.android.FragmentStore
+import com.instacart.formula.android.events.FragmentLifecycleEvent
import com.instacart.formula.test.TestBackCallbackRenderModel
import com.instacart.formula.test.TestKey
import com.instacart.formula.test.TestKeyWithId
@@ -43,6 +44,7 @@ class FormulaFragmentTest {
private var onPreCreated: (TestFragmentActivity) -> Unit = {}
private var updateThreads = linkedSetOf()
private val errors = mutableListOf()
+ private val fragmentLifecycleEvents = mutableListOf()
private val formulaRule = TestFormulaRule(
initFormula = { app ->
val environment = FragmentEnvironment(
@@ -74,6 +76,9 @@ class FormulaFragmentTest {
stateChanges(it)
}
))
+ },
+ onFragmentLifecycleEvent = {
+ fragmentLifecycleEvents.add(it)
}
)
}
@@ -83,6 +88,7 @@ class FormulaFragmentTest {
cleanUp = {
lastState = null
updateThreads = linkedSetOf()
+ fragmentLifecycleEvents.clear()
}
)
@@ -104,6 +110,11 @@ class FormulaFragmentTest {
navigateBack()
assertThat(activeContracts()).containsExactly(TestKey()).inOrder()
+
+ assertThat(fragmentLifecycleEvents).hasSize(3)
+ assertThat(fragmentLifecycleEvents[0]).isInstanceOf(FragmentLifecycleEvent.Added::class.java)
+ assertThat(fragmentLifecycleEvents[1]).isInstanceOf(FragmentLifecycleEvent.Added::class.java)
+ assertThat(fragmentLifecycleEvents[2]).isInstanceOf(FragmentLifecycleEvent.Removed::class.java)
}
@Test fun `navigating forward should have both keys in backstack`() {
diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt
index f857a5c9a..00f7efaf1 100644
--- a/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt
+++ b/formula-android-tests/src/test/java/com/instacart/formula/FragmentLifecycleTest.kt
@@ -53,7 +53,7 @@ class FragmentLifecycleTest {
}
}
bind(featureFactory)
- }
+ },
)
}
}
diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt
new file mode 100644
index 000000000..decae86ff
--- /dev/null
+++ b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundActivityTest.kt
@@ -0,0 +1,43 @@
+package com.instacart.formula
+
+import android.app.Activity
+import androidx.fragment.app.FragmentActivity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+
+/**
+ * Tests that formula-android module handles non-bound activities gracefully.
+ */
+@RunWith(AndroidJUnit4::class)
+class NonBoundActivityTest {
+ class TestActivity : Activity()
+
+ private val formulaRule = TestFormulaRule(
+ initFormula = { app ->
+ FormulaAndroid.init(app) {}
+ }
+ )
+
+ private val activityRule = ActivityScenarioRule(TestActivity::class.java)
+
+ @get:Rule
+ val rule = RuleChain.outerRule(formulaRule).around(activityRule)
+ lateinit var scenario: ActivityScenario
+
+ @Before
+ fun setup() {
+ scenario = activityRule.scenario
+ }
+
+ @Test
+ fun `full lifecycle`() {
+ scenario.recreate()
+ scenario.close()
+ }
+}
\ No newline at end of file
diff --git a/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt
new file mode 100644
index 000000000..56144c143
--- /dev/null
+++ b/formula-android-tests/src/test/java/com/instacart/formula/NonBoundFragmentActivityTest.kt
@@ -0,0 +1,42 @@
+package com.instacart.formula
+
+import androidx.fragment.app.FragmentActivity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+
+/**
+ * Tests that formula-android module handles non-bound activities gracefully.
+ */
+@RunWith(AndroidJUnit4::class)
+class NonBoundFragmentActivityTest {
+ class TestActivity : FragmentActivity()
+
+ private val formulaRule = TestFormulaRule(
+ initFormula = { app ->
+ FormulaAndroid.init(app) {}
+ }
+ )
+
+ private val activityRule = ActivityScenarioRule(TestActivity::class.java)
+
+ @get:Rule
+ val rule = RuleChain.outerRule(formulaRule).around(activityRule)
+ lateinit var scenario: ActivityScenario
+
+ @Before
+ fun setup() {
+ scenario = activityRule.scenario
+ }
+
+ @Test
+ fun `full lifecycle`() {
+ scenario.recreate()
+ scenario.close()
+ }
+}
\ No newline at end of file
diff --git a/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt b/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt
index 40ea1c766..5663c3f0b 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/ActivityConfigurator.kt
@@ -8,7 +8,7 @@ import kotlin.reflect.KClass
*/
class ActivityConfigurator {
internal class Binding(
- val init: ActivityStoreContext.() -> ActivityStore?
+ val init: ActivityStoreContext.() -> ActivityStore
)
internal val bindings = mutableMapOf, Binding<*>>()
diff --git a/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt b/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt
index d30a2639d..8fc523770 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/events/FragmentLifecycleEvent.kt
@@ -6,7 +6,7 @@ import com.instacart.formula.android.FragmentId
* Models when a fragment key is attached and detached. Provides a way to indicate
* when to initialize state stream and when to destroy it.
*/
-sealed class FragmentLifecycleEvent() {
+sealed class FragmentLifecycleEvent {
abstract val fragmentId: FragmentId
diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt
index 27129ce4e..e2f88b34b 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/internal/ActivityStoreFactory.kt
@@ -24,7 +24,7 @@ internal class ActivityStoreFactory internal constructor(
?: return null
val activityDelegate = ActivityStoreContextImpl()
- return initializer.init.invoke(activityDelegate)?.let { store ->
+ return initializer.init.invoke(activityDelegate).let { store ->
ActivityManager(
environment = environment,
delegate = activityDelegate,
diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt
index 175bd45b7..02f28279f 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/internal/AppManager.kt
@@ -99,7 +99,12 @@ internal class AppManager(
private fun findOrInitActivityStore(
activity: FragmentActivity, savedState: Bundle?
): ActivityManager {
- val key = findOrGenerateActivityKey(activity, savedState) // generate new key
+ if (activityToKeyMap.containsKey(activity)) {
+ throw IllegalStateException("Activity ${activity::class.java.simpleName} was already initialized. Did you call FormulaAndroid.onPreCreate() twice?")
+ }
+
+ val key = savedState?.getString(BUNDLE_KEY) // Activity recreated, let's use saved key
+ ?: UUID.randomUUID().toString() // New activity, create new key
activityToKeyMap[activity] = key
val cached = componentMap[key] as? ActivityManager?
@@ -121,13 +126,4 @@ internal class AppManager(
val component = componentMap.remove(key)
component?.dispose()
}
-
- /**
- * Key is persisted across configuration changes.
- */
- private fun findOrGenerateActivityKey(activity: Activity, savedState: Bundle?): String {
- return (activityToKeyMap[activity] // Try the map
- ?: savedState?.getString(BUNDLE_KEY) // Try the bundle
- ?: UUID.randomUUID().toString())
- }
}
diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt
index 08c3511c4..f6ac5f105 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FormulaFragmentViewFactory.kt
@@ -2,6 +2,7 @@ package com.instacart.formula.android.internal
import android.view.LayoutInflater
import android.view.ViewGroup
+import androidx.annotation.VisibleForTesting
import com.instacart.formula.android.FeatureView
import com.instacart.formula.android.ViewFactory
import com.instacart.formula.android.FeatureEvent
@@ -18,9 +19,22 @@ internal class FormulaFragmentViewFactory(
private var factory: ViewFactory? = null
override fun create(inflater: LayoutInflater, container: ViewGroup?): FeatureView {
+ val viewFactory = viewFactory()
+ val delegate = environment.fragmentDelegate
+ return delegate.createView(fragmentId, viewFactory, inflater, container)
+ }
+
+ @VisibleForTesting
+ internal fun viewFactory(): ViewFactory {
+ return factory ?: findViewFactory().apply {
+ factory = this
+ }
+ }
+
+ private fun findViewFactory(): ViewFactory {
val key = fragmentId.key
val featureEvent = featureProvider.getFeature(fragmentId) ?: throw IllegalStateException("Could not find feature for $key.")
- val viewFactory = factory ?: when (featureEvent) {
+ return when (featureEvent) {
is FeatureEvent.MissingBinding -> {
throw IllegalStateException("Missing feature factory or integration for $key. Please check your FragmentStore configuration.")
}
@@ -31,8 +45,5 @@ internal class FormulaFragmentViewFactory(
featureEvent.feature.viewFactory
}
}
- this.factory = viewFactory
- val delegate = environment.fragmentDelegate
- return delegate.createView(fragmentId, viewFactory, inflater, container)
}
}
\ No newline at end of file
diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt
index a26acc19a..da23b3dfd 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowRenderView.kt
@@ -35,11 +35,12 @@ internal class FragmentFlowRenderView(
) {
private var fragmentState: FragmentState? = null
+ private var features: Map = emptyMap()
private val visibleFragments: LinkedList = LinkedList()
private val featureProvider = object : FeatureProvider {
override fun getFeature(id: FragmentId): FeatureEvent? {
- return fragmentState?.features?.get(id)
+ return features[id]
}
}
@@ -53,10 +54,7 @@ internal class FragmentFlowRenderView(
super.onFragmentViewCreated(fm, f, v, savedInstanceState)
visibleFragments.add(f)
-
- fragmentState?.let {
- updateVisibleFragments(it)
- }
+ updateVisibleFragments()
onFragmentViewStateChanged(f.getFormulaFragmentId(), true)
notifyLifecycleStateChanged(f, Lifecycle.State.CREATED)
@@ -93,7 +91,10 @@ internal class FragmentFlowRenderView(
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
if (FragmentLifecycle.shouldTrack(f)) {
- onLifecycleEvent(FragmentLifecycle.createAddedEvent(f))
+ val event = FragmentLifecycleEvent.Added(
+ fragmentId = f.getFormulaFragmentId(),
+ )
+ onLifecycleEvent(event)
} else {
fragmentEnvironment.logger("Ignoring attach event for fragment: $f")
}
@@ -103,8 +104,12 @@ internal class FragmentFlowRenderView(
super.onFragmentDetached(fm, f)
// Only trigger detach, when fragment is actually being removed from the backstack
- if (FragmentLifecycle.shouldTrack(f) && !FragmentLifecycle.isKept(fm, f)) {
- val event = FragmentLifecycle.createRemovedEvent(f)
+ if (FragmentLifecycle.shouldTrack(f) && f.isRemoving) {
+ val formulaFragment = f as? BaseFormulaFragment<*>
+ val event = FragmentLifecycleEvent.Removed(
+ fragmentId = f.getFormulaFragmentId(),
+ lastState = formulaFragment?.currentState(),
+ )
onLifecycleEvent(event)
}
}
@@ -118,7 +123,8 @@ internal class FragmentFlowRenderView(
Utils.assertMainThread()
fragmentState = state
- updateVisibleFragments(state)
+ features = state.features
+ updateVisibleFragments()
}
fun onBackPressed(): Boolean {
@@ -149,7 +155,8 @@ internal class FragmentFlowRenderView(
onLifecycleState.invoke(fragment.getFormulaFragmentId(), newState)
}
- private fun updateVisibleFragments(state: FragmentState) {
+ private fun updateVisibleFragments() {
+ val state = fragmentState ?: return
visibleFragments.forEachIndices { fragment ->
if (fragment is BaseFormulaFragment<*>) {
state.outputs[fragment.getFormulaFragmentId()]?.let {
diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt
index 6c9f2dd7c..5ea3d78e2 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentLifecycle.kt
@@ -3,12 +3,10 @@ package com.instacart.formula.android.internal
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentInspector
-import androidx.fragment.app.FragmentManager
import com.instacart.formula.android.FragmentId
import com.instacart.formula.android.FragmentKey
import com.instacart.formula.android.BaseFormulaFragment
import com.instacart.formula.android.FormulaFragment
-import com.instacart.formula.android.events.FragmentLifecycleEvent
import java.util.UUID
/**
@@ -19,19 +17,6 @@ internal object FragmentLifecycle {
internal fun shouldTrack(fragment: Fragment): Boolean {
return !fragment.retainInstance && !FragmentInspector.isHeadless(fragment)
}
-
- internal fun isKept(fragmentManager: FragmentManager, fragment: Fragment): Boolean {
- return !fragment.isRemoving
- }
-
- internal fun createAddedEvent(f: Fragment): FragmentLifecycleEvent.Added {
- return FragmentLifecycleEvent.Added(f.getFormulaFragmentId())
- }
-
- internal fun createRemovedEvent(f: Fragment): FragmentLifecycleEvent.Removed {
- val fragment = f as? BaseFormulaFragment<*>
- return FragmentLifecycleEvent.Removed(f.getFormulaFragmentId(), fragment?.currentState())
- }
}
private fun Fragment.getFragmentKey(): FragmentKey {
@@ -47,12 +32,10 @@ private fun Fragment.getFragmentInstanceId(): String {
return if (this is BaseFormulaFragment<*>) {
val arguments = getOrSetArguments()
val id = arguments.getString(FormulaFragment.ARG_FORMULA_ID, "")
- if (id.isNullOrBlank()) {
+ id.ifBlank {
val initializedId = UUID.randomUUID().toString()
arguments.putString(FormulaFragment.ARG_FORMULA_ID, initializedId)
initializedId
- } else {
- id
}
} else {
""
diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt
index 1f3e7da8a..3b4be24cf 100644
--- a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt
+++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentStoreFormula.kt
@@ -94,12 +94,8 @@ internal class FragmentStoreFormula(
feature = feature,
)
action.onEvent {
- if (state.activeIds.contains(fragmentId)) {
- val keyState = FragmentOutput(fragmentId.key, it)
- transition(state.copy(outputs = state.outputs.plus(fragmentId to keyState)))
- } else {
- none()
- }
+ val keyState = FragmentOutput(fragmentId.key, it)
+ transition(state.copy(outputs = state.outputs.plus(fragmentId to keyState)))
}
}
}
diff --git a/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt b/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt
index 13414be72..a1b78726a 100644
--- a/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt
+++ b/formula-android/src/test/java/com/instacart/formula/android/ActivityStoreFactoryTest.kt
@@ -26,4 +26,14 @@ class ActivityStoreFactoryTest {
val store = factory.init(mock())!!
assertThat(store.stateSubscription.isDisposed).isFalse()
}
+
+ @Test fun `returns null if no binding for activity is found`() {
+ val factory = ActivityStoreFactory(
+ environment = FragmentEnvironment(),
+ activities = {}
+ )
+
+ val store = factory.init(mock())
+ assertThat(store).isNull()
+ }
}
diff --git a/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt
index f8d067c0f..fecd2ab61 100644
--- a/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt
+++ b/formula-android/src/test/java/com/instacart/formula/android/FragmentStoreTest.kt
@@ -8,11 +8,12 @@ import com.instacart.formula.android.fakes.FakeComponent
import com.instacart.formula.android.fakes.MainKey
import com.instacart.formula.android.events.FragmentLifecycleEvent
import com.instacart.formula.android.fakes.NoOpViewFactory
+import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.observers.TestObserver
+import io.reactivex.rxjava3.subjects.PublishSubject
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows
-import java.lang.RuntimeException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@@ -204,6 +205,130 @@ class FragmentStoreTest {
)
}
+ @Test fun `fragment store ignores events after key is removed`() {
+ val stateSubject = PublishSubject.create()
+
+ val store = FragmentStore.init {
+ val featureFactory = object : FeatureFactory {
+ override fun initialize(dependencies: Any, key: MainKey): Feature {
+ return Feature(
+ state = stateSubject,
+ viewFactory = NoOpViewFactory(),
+ )
+ }
+ }
+ bind(featureFactory)
+ }
+
+ val observer = store.state(FragmentEnvironment()).test()
+ val fragmentId = FragmentId("", MainKey(1))
+ store.onLifecycleEffect(
+ FragmentLifecycleEvent.Added(fragmentId = fragmentId)
+ )
+ stateSubject.onNext("value")
+
+ // Check that first event was shown
+ val firstModel = observer.values().last().outputs[fragmentId]?.renderModel
+ assertThat(firstModel).isEqualTo("value")
+
+ // Remove fragment
+ store.onLifecycleEffect(
+ FragmentLifecycleEvent.Removed(fragmentId = fragmentId)
+ )
+
+ // Check that new events are ignored
+ stateSubject.onNext("new-value")
+
+ // Output should not exist
+ val secondModel = observer.values().last().outputs[fragmentId]
+ assertThat(secondModel).isNull()
+ }
+
+ @Test fun `feature observable error emits on screen error and finishes`() {
+ val stateSubject = PublishSubject.create()
+
+ val store = FragmentStore.init {
+ val featureFactory = object : FeatureFactory {
+ override fun initialize(dependencies: Any, key: MainKey): Feature {
+ return Feature(
+ state = stateSubject,
+ viewFactory = NoOpViewFactory(),
+ )
+ }
+ }
+ bind(featureFactory)
+ }
+
+ val screenErrors = mutableListOf>()
+ val environment = FragmentEnvironment(
+ onScreenError = { key, error ->
+ screenErrors.add(key to error)
+ }
+ )
+ val observer = store.state(environment).test()
+ val fragmentId = FragmentId("", MainKey(1))
+ store.onLifecycleEffect(
+ FragmentLifecycleEvent.Added(fragmentId = fragmentId)
+ )
+ stateSubject.onNext("value")
+
+ val firstModel = observer.values().last().outputs[fragmentId]?.renderModel
+ assertThat(firstModel).isEqualTo("value")
+
+ // Emit error
+ val error = RuntimeException("error")
+ stateSubject.onError(error)
+
+ // Model didn't change
+ val secondModel = observer.values().last().outputs[fragmentId]?.renderModel
+ assertThat(secondModel).isEqualTo("value")
+
+ // Store observable didn't crash
+ observer.assertNoErrors()
+
+ assertThat(screenErrors).containsExactly(
+ fragmentId.key to error
+ )
+ }
+
+ @Test fun `fragment store visible output`() {
+ val store = FragmentStore.init {
+ val featureFactory = object : FeatureFactory {
+ override fun initialize(dependencies: Any, key: MainKey): Feature {
+ return Feature(
+ state = Observable.just("value"),
+ viewFactory = NoOpViewFactory(),
+ )
+ }
+ }
+ bind(featureFactory)
+ }
+
+ val observer = store.state(FragmentEnvironment()).test()
+ val fragmentId = FragmentId("", MainKey(1))
+ store.onLifecycleEffect(
+ FragmentLifecycleEvent.Added(fragmentId = fragmentId)
+ )
+
+ // No visible output yet
+ val firstModel = observer.values().last().visibleOutput()
+ assertThat(firstModel).isNull()
+
+ // Toggle visibility
+ store.onVisibilityChanged(fragmentId, true)
+
+ // Check that visible output is now present
+ val secondModel = observer.values().last().visibleOutput()
+ assertThat(secondModel).isNotNull()
+
+ // Toggle visibility again
+ store.onVisibilityChanged(fragmentId, false)
+
+ // Check that visible output is null again
+ val third = observer.values().last().visibleOutput()
+ assertThat(third).isNull()
+ }
+
private fun FragmentStore.toStates(): TestObserver