blob: f156f7274a9502efd4239fc3e8e914b3fcc269ae [file] [log] [blame]
/*
* Copyright 2020 The Android Open Source Project
*
* 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 androidx.compose.runtime
import android.view.Choreographer
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
import kotlin.test.fail
@Suppress("UNUSED_VARIABLE")
@MediumTest
@RunWith(AndroidJUnit4::class)
class SideEffectTests : BaseComposeTest() {
@get:Rule
override val activityRule = makeTestActivityRule()
/**
* Test that side effects run in order of appearance each time the composable
* is recomposed.
*/
@Test
fun testSideEffectsRunInOrder() {
val results = mutableListOf<Int>()
var resultsAtComposition: List<Int>? = null
var scope: RecomposeScope? = null
compose {
SideEffect {
results += 1
}
SideEffect {
results += 2
}
resultsAtComposition = results.toList()
scope = currentRecomposeScope
}.then {
assertEquals(listOf(1, 2), results, "side effects were applied")
assertEquals(
emptyList(), resultsAtComposition,
"side effects weren't applied until after composition"
)
scope?.invalidate() ?: error("missing recompose function")
}.then {
assertEquals(listOf(1, 2, 1, 2), results, "side effects applied a second time")
}
}
/**
* Test that side effects run after lifecycle observers enter the composition,
* even if their remembrance happens after the SideEffect call appears.
*/
@Test
fun testSideEffectsRunAfterLifecycleObservers() {
class MyObserver : RememberObserver {
var isPresent: Boolean = false
private set
override fun onRemembered() {
isPresent = true
}
override fun onForgotten() {
isPresent = false
}
override fun onAbandoned() {
fail("Unexpected call to onAbandoned")
}
}
val myObserverOne = MyObserver()
val myObserverTwo = MyObserver()
var wasObserverOnePresent = false
var wasObserverTwoPresent = false
compose {
val one = remember { myObserverOne }
SideEffect {
wasObserverOnePresent = myObserverOne.isPresent
wasObserverTwoPresent = myObserverTwo.isPresent
}
val two = remember { myObserverTwo }
}.then {
assertTrue(wasObserverOnePresent, "observer one present for side effect")
assertTrue(wasObserverTwoPresent, "observer two present for side effect")
}
}
@Test
fun testDisposableEffectExecutionOrder() {
var mount by mutableStateOf(true)
val logHistory = mutableListOf<String>()
fun log(x: String) = logHistory.add(x)
@Composable
fun Unmountable() {
log("Unmountable:start")
DisposableEffect(Unit) {
log("DisposableEffect")
onDispose {
log("onDispose")
}
}
log("Unmountable:end")
}
compose {
log("compose:start")
if (mount) {
Unmountable()
}
log("compose:end")
}.then { _ ->
assertEquals(
listOf(
"compose:start",
"Unmountable:start",
"Unmountable:end",
"compose:end",
"DisposableEffect"
),
logHistory
)
mount = false
}.then { _ ->
assertEquals(
listOf(
"compose:start",
"Unmountable:start",
"Unmountable:end",
"compose:end",
"DisposableEffect",
"compose:start",
"compose:end",
"onDispose"
),
logHistory
)
}
}
@Test
fun testDisposableEffectRelativeOrdering() {
var mount by mutableStateOf(true)
val logHistory = mutableListOf<String>()
fun log(x: String) = logHistory.add(x)
@Composable
fun Unmountable() {
DisposableEffect(Unit) {
log("DisposableEffect:a2")
onDispose {
log("onDispose:a2")
}
}
DisposableEffect(Unit) {
log("DisposableEffect:b2")
onDispose {
log("onDispose:b2")
}
}
}
compose {
DisposableEffect(NeverEqualObject) {
log("DisposableEffect:a1")
onDispose {
log("onDispose:a1")
}
}
if (mount) {
Unmountable()
}
DisposableEffect(NeverEqualObject) {
log("DisposableEffect:b1")
onDispose {
log("onDispose:b1")
}
}
}.then { _ ->
assertEquals(
listOf(
"DisposableEffect:a1",
"DisposableEffect:a2",
"DisposableEffect:b2",
"DisposableEffect:b1"
),
logHistory
)
mount = false
log("recompose")
}.then { _ ->
assertEquals(
listOf(
"DisposableEffect:a1",
"DisposableEffect:a2",
"DisposableEffect:b2",
"DisposableEffect:b1",
"recompose",
"onDispose:b1",
"onDispose:b2",
"onDispose:a2",
"onDispose:a1",
"DisposableEffect:a1",
"DisposableEffect:b1"
),
logHistory
)
}
}
@Test
fun testDisposableEffectKeyChange() {
var x = 0
var key = 123
lateinit var scope: RecomposeScope
val logHistory = mutableListOf<String>()
fun log(x: String) = logHistory.add(x)
compose {
scope = currentRecomposeScope
DisposableEffect(key) {
val y = x++
log("DisposableEffect:$y")
onDispose {
log("dispose:$y")
}
}
}.then { _ ->
log("recompose")
scope.invalidate()
}.then { _ ->
assertEquals(
listOf(
"DisposableEffect:0",
"recompose"
),
logHistory
)
log("recompose (key -> 345)")
key = 345
scope.invalidate()
}.then { _ ->
assertEquals(
listOf(
"DisposableEffect:0",
"recompose",
"recompose (key -> 345)",
"dispose:0",
"DisposableEffect:1"
),
logHistory
)
}
}
@Test
fun testLaunchedEffect() {
var counter by mutableStateOf(0)
// Used as a signal that LaunchedEffect will await
val ch = Channel<Unit>(Channel.CONFLATED)
compose {
LaunchedEffect(ch) {
counter++
ch.receive()
counter++
ch.receive()
counter++
}
}.then {
assertEquals(1, counter)
ch.trySend(Unit)
}.then {
assertEquals(2, counter)
ch.trySend(Unit)
}.then {
assertEquals(3, counter)
}
}
@Test
fun testAwaitFrameFromLaunchedEffect() {
var choreographerTime by mutableStateOf(Long.MIN_VALUE)
var awaitFrameTime by mutableStateOf(Long.MAX_VALUE)
compose {
LaunchedEffect(Unit) {
withFrameNanos {
awaitFrameTime = it
}
}
DisposableEffect(true) {
Choreographer.getInstance().postFrameCallback { frameTimeNanos ->
choreographerTime = frameTimeNanos
}
onDispose { }
}
}.then {
assertNotEquals(choreographerTime, Long.MIN_VALUE, "Choreographer callback never ran")
assertNotEquals(awaitFrameTime, Long.MAX_VALUE, "awaitFrameNanos callback never ran")
assertEquals(
choreographerTime, awaitFrameTime,
"expected same values from choreographer post and awaitFrameNanos"
)
}
}
@Test
fun testLaunchedEffectRunsAfter() {
var onCommitRan = false
var launchRanAfter = false
compose {
// Confirms that these run "out of order" with respect to one another because
// the launch runs dispatched.
LaunchedEffect(Unit) {
launchRanAfter = onCommitRan
}
SideEffect {
onCommitRan = true
}
}.then {
assertTrue(launchRanAfter, "expected LaunchedEffect to run after later onCommit")
}
}
@OptIn(InternalComposeApi::class)
@Test
fun testCoroutineScopesHaveCorrectFrameClock() {
var recomposerClock: MonotonicFrameClock? = null
var LaunchedEffectClock: MonotonicFrameClock? = null
var rememberCoroutineScopeFrameClock: MonotonicFrameClock? = null
compose {
recomposerClock = currentComposer.applyCoroutineContext[MonotonicFrameClock]
LaunchedEffect(Unit) {
LaunchedEffectClock = coroutineContext[MonotonicFrameClock]
}
val rememberedScope = rememberCoroutineScope()
SideEffect {
rememberCoroutineScopeFrameClock =
rememberedScope.coroutineContext[MonotonicFrameClock]
}
}.then {
assertNotNull(recomposerClock, "Recomposer frameClock")
assertSame(recomposerClock, LaunchedEffectClock, "LaunchedEffect clock")
assertSame(
recomposerClock, rememberCoroutineScopeFrameClock,
"rememberCoroutineScope clock"
)
}
}
@Test
fun testRememberUpdatedStateRecomposition() {
@Composable
fun MyComposable(
arg: String,
inCh: ReceiveChannel<Unit>,
outCh: SendChannel<String>
) {
val currentArg by rememberUpdatedState(arg)
// This block closes over currentArg and is long-lived; it is important that the
// value used be updated by recomposition of MyComposable
LaunchedEffect(inCh, outCh) {
inCh.receive()
outCh.send(currentArg)
}
}
var myComposableArg by mutableStateOf("hello")
val pleaseSend = Channel<Unit>()
val output = Channel<String>()
compose {
MyComposable(myComposableArg, pleaseSend, output)
}.then {
myComposableArg = "world"
}.then {
val offerSucceeded = pleaseSend.trySend(Unit).isSuccess
assertTrue(offerSucceeded, "task wasn't awaiting send signal")
}.then {
val receivedResult = output.tryReceive().getOrNull()
assertEquals("world", receivedResult)
}
}
/**
* Always compares as unequal to itself (and everything else) to force DisposableEffect
* to recompose on every recomposition
*/
@Suppress("EqualsOrHashCode")
private object NeverEqualObject {
override fun equals(other: Any?) = false
override fun hashCode() = 42
}
}