diff --git a/litho-core/src/main/java/com/facebook/litho/LithoView.java b/litho-core/src/main/java/com/facebook/litho/LithoView.java index 2b9be82e831..af59f95af3d 100644 --- a/litho-core/src/main/java/com/facebook/litho/LithoView.java +++ b/litho-core/src/main/java/com/facebook/litho/LithoView.java @@ -706,6 +706,10 @@ protected void onDetached() { super.onDetached(); if (mComponentTree != null) { mComponentTree.detach(); + + if (mComponentTree.getLithoConfiguration().componentsConfig.unmountOnDetachedFromWindow) { + unmountAllItems(); + } } AccessibilityManagerCompat.removeAccessibilityStateChangeListener( diff --git a/litho-core/src/main/java/com/facebook/litho/config/ComponentsConfiguration.kt b/litho-core/src/main/java/com/facebook/litho/config/ComponentsConfiguration.kt index bbdc6180a57..a69ee645a85 100644 --- a/litho-core/src/main/java/com/facebook/litho/config/ComponentsConfiguration.kt +++ b/litho-core/src/main/java/com/facebook/litho/config/ComponentsConfiguration.kt @@ -63,6 +63,14 @@ internal constructor( */ @JvmField val shouldNotifyVisibleBoundsChangeWhenNestedLithoViewBecomesInvisible: Boolean = false, + /** + * If enabled, then the [com.facebook.litho.LithoView] will attempt to unmount any mounted + * content of the mount state when it gets detached from window. + * + * This is done to tackle and edge case where mount contents that have the same top/bottom + * boundaries of the host view are not mounted by incremental mount. + */ + @JvmField val unmountOnDetachedFromWindow: Boolean = false, /** Whether the [ComponentTree] should be using State Reconciliation. */ @JvmField val isReconciliationEnabled: Boolean = true, /** The handler [ComponentTree] will be used to run the pre-allocation process */ @@ -283,6 +291,7 @@ internal constructor( private var shouldBuildRenderTreeInBg = baseConfig.shouldBuildRenderTreeInBg private var enablePreAllocationSameThreadCheck = baseConfig.enablePreAllocationSameThreadCheck private var avoidRedundantPreAllocations = baseConfig.avoidRedundantPreAllocations + private var unmountOnDetachedFromWindow = baseConfig.unmountOnDetachedFromWindow fun shouldNotifyVisibleBoundsChangeWhenNestedLithoViewBecomesInvisible( enabled: Boolean @@ -360,6 +369,10 @@ internal constructor( avoidRedundantPreAllocations = value } + fun unmountOnDetachedFromWindow(unmountOnDetachedFromWindow: Boolean): Builder = also { + this.unmountOnDetachedFromWindow = unmountOnDetachedFromWindow + } + fun build(): ComponentsConfiguration { return baseConfig.copy( specsApiStateUpdateDuplicateDetectionEnabled = @@ -390,7 +403,7 @@ internal constructor( shouldReuseIdToPositionMap = shouldBuildRenderTreeInBg, enablePreAllocationSameThreadCheck = enablePreAllocationSameThreadCheck, avoidRedundantPreAllocations = avoidRedundantPreAllocations, - ) + unmountOnDetachedFromWindow = unmountOnDetachedFromWindow) } } } diff --git a/litho-it/src/test/java/com/facebook/litho/UnmountOnDetachTest.kt b/litho-it/src/test/java/com/facebook/litho/UnmountOnDetachTest.kt new file mode 100644 index 00000000000..d6bb59a7608 --- /dev/null +++ b/litho-it/src/test/java/com/facebook/litho/UnmountOnDetachTest.kt @@ -0,0 +1,125 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +package com.facebook.litho + +import android.content.Context +import android.view.View +import com.facebook.litho.binders.viewBinder +import com.facebook.litho.config.ComponentsConfiguration +import com.facebook.litho.kotlin.widget.Text +import com.facebook.litho.testing.LithoViewRule +import com.facebook.litho.testing.assertj.LithoAssertions +import com.facebook.litho.testing.testrunner.LithoTestRunner +import com.facebook.rendercore.RenderUnit +import org.assertj.core.api.Assertions +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This test focuses on testing the behavior around + * [ComponentsConfiguration.unmountOnDetachedFromWindow]. + */ +@RunWith(LithoTestRunner::class) +class UnmountOnDetachTest { + + @get:Rule val lithoRule = LithoViewRule() + + @Test + fun `should not unmount on detach when unmount on detach is disabled`() { + var mountCount = 0 + var unmountCount = 0 + val component = + UnmountOnDetachComponent( + "Mount Text", onBind = { mountCount++ }, onUnbind = { unmountCount++ }) + + val lithoView = + lithoRule.render( + componentTree = + ComponentTree.create(lithoRule.context) + .componentsConfiguration( + ComponentsConfiguration.defaultInstance.copy( + unmountOnDetachedFromWindow = false)) + .build()) { + component + } + + LithoAssertions.assertThat(lithoView).hasVisibleText("Mount Text") + + lithoView.detachFromWindow() + + Assertions.assertThat(mountCount).isEqualTo(1) + Assertions.assertThat(unmountCount).isEqualTo(0) + } + + @Test + fun `should unmount on detach when unmount on detach is enabled`() { + var mountCount = 0 + var unmountCount = 0 + val component = + UnmountOnDetachComponent( + "Mount Text", onBind = { mountCount++ }, onUnbind = { unmountCount++ }) + + val lithoView = + lithoRule.render( + componentTree = + ComponentTree.create(lithoRule.context) + .componentsConfiguration( + ComponentsConfiguration.defaultInstance.copy( + unmountOnDetachedFromWindow = true)) + .build()) { + component + } + + LithoAssertions.assertThat(lithoView).hasVisibleText("Mount Text") + + lithoView.detachFromWindow() + + Assertions.assertThat(mountCount).isEqualTo(1) + Assertions.assertThat(unmountCount).isEqualTo(1) + } + + private class UnmountOnDetachComponent( + private val name: String, + private val onBind: () -> Unit, + private val onUnbind: () -> Unit + ) : KComponent() { + + override fun ComponentScope.render(): Component? { + return Text( + text = name, + style = + Style.viewBinder( + RenderUnit.DelegateBinder.createDelegateBinder( + Unit, + object : RenderUnit.Binder { + override fun shouldUpdate( + currentModel: Any, + newModel: Any, + currentLayoutData: Any?, + nextLayoutData: Any? + ): Boolean = false + + override fun bind( + context: Context, + content: View, + model: Any, + layoutData: Any? + ): Any? { + onBind() + return Unit + } + + override fun unbind( + context: Context, + content: View, + model: Any, + layoutData: Any?, + bindData: Any? + ) { + onUnbind() + } + }))) + } + } +} diff --git a/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/LithoTestRunner.java b/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/LithoTestRunner.java index b8c2f2c6fe3..9342a1bcd68 100644 --- a/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/LithoTestRunner.java +++ b/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/LithoTestRunner.java @@ -73,7 +73,7 @@ public LithoTestRunner(final Class testClass) throws InitializationError { * configurations. */ private List> getGlobalConfigs() { - return Arrays.asList(); + return Arrays.asList(UnmountOnDetachConfiguration.class); } @Override diff --git a/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/UnmountOnDetachConfiguration.kt b/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/UnmountOnDetachConfiguration.kt new file mode 100644 index 00000000000..2f638f31aff --- /dev/null +++ b/litho-testing/src/main/java/com/facebook/litho/testing/testrunner/UnmountOnDetachConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.facebook.litho.testing.testrunner + +import com.facebook.litho.config.ComponentsConfiguration +import org.junit.runners.model.FrameworkMethod + +class UnmountOnDetachConfiguration : LithoTestRunConfiguration { + + private val defaultInstance = ComponentsConfiguration.defaultInstance + + override fun beforeTest(method: FrameworkMethod) { + ComponentsConfiguration.defaultInstance = + defaultInstance.copy(unmountOnDetachedFromWindow = true) + } + + override fun afterTest(method: FrameworkMethod) { + ComponentsConfiguration.defaultInstance = defaultInstance + } +}