All Downloads are FREE. Search and download functionalities are using the official Maven repository.

main.io.nlopez.compose.rules.LambdaParameterInRestartableEffect.kt Maven / Gradle / Ivy

There is a newer version: 0.4.19
Show newest version
// Copyright 2024 Nacho Lopez
// SPDX-License-Identifier: Apache-2.0
package io.nlopez.compose.rules

import io.nlopez.compose.core.ComposeKtConfig
import io.nlopez.compose.core.ComposeKtVisitor
import io.nlopez.compose.core.Emitter
import io.nlopez.compose.core.report
import io.nlopez.compose.core.util.findChildrenByClass
import io.nlopez.compose.core.util.isComposable
import io.nlopez.compose.core.util.isLambda
import io.nlopez.compose.core.util.isRestartableEffect
import io.nlopez.compose.core.util.lambdaTypes
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtFunction
import org.jetbrains.kotlin.psi.KtIfExpression
import org.jetbrains.kotlin.psi.KtReferenceExpression

class LambdaParameterInRestartableEffect : ComposeKtVisitor {
    override fun visitFile(file: KtFile, autoCorrect: Boolean, emitter: Emitter, config: ComposeKtConfig) {
        val lambdaTypes = with(config) { file.lambdaTypes }
        val composables = file.findChildrenByClass()
            .filter { it.isComposable }

        for (composable in composables) {
            // We are only interested in composables with restartable effects (the ones that have keys)
            val effects = composable.findChildrenByClass()
                .filter { it.isRestartableEffect }

            if (effects.none()) continue

            // And of those, we are only interested in composables that have lambda params
            val lambdaParameters = composable.valueParameters
                .filter { it.isLambdaParameter || it.typeReference?.isLambda(lambdaTypes) == true }
                .filter { it.name != null }
                .associateBy { it.name!! }
            val lambdaParameterNames = lambdaParameters.keys

            if (lambdaParameterNames.isEmpty()) continue

            // Then, we just want the lambda parameters that are actually used inside any of the found effects'
            // trailing lambda code
            val usedLambdaParameterNames = effects
                .flatMap { effect ->
                    val body = effect.lambdaArguments.lastOrNull()?.getLambdaExpression()?.bodyExpression
                        ?: return@flatMap emptySequence()

                    val callExpressions = body.findChildrenByClass()
                    val isDisposableEffect = effect.calleeExpression?.text == "DisposableEffect"

                    // Lambdas used directly: myLambda()
                    val invoked = callExpressions
                        .let { expressions ->
                            if (isDisposableEffect) {
                                expressions.filter { it.calleeExpression?.text != "onDispose" }
                            } else {
                                expressions
                            }
                        }
                        .mapNotNull { it.calleeExpression?.text }
                        .filter { it in lambdaParameterNames }

                    // Lambdas being tossed around to other methods
                    val forwarded = callExpressions.flatMap {
                        it.valueArguments.mapNotNull { argument ->
                            when (val expression = argument.getArgumentExpression()) {
                                // something(myLambda) || something(a = myLambda)
                                is KtReferenceExpression -> {
                                    if (expression.text in lambdaParameterNames) expression.text else null
                                }
                                // something(if (x) myLambda else otherThing)
                                is KtIfExpression -> {
                                    if (expression.then?.text in lambdaParameterNames) {
                                        expression.then?.text
                                    } else if (expression.`else`?.text in lambdaParameterNames) {
                                        expression.`else`?.text
                                    } else {
                                        null
                                    }
                                }

                                else -> null
                            }
                        }
                    }
                    invoked + forwarded
                }
                .toSet()

            // We want to filter out the parameters that are used as key in the restartable effect
            val keyedLambdaParameterNames = effects.flatMap { it.valueArguments }
                .mapNotNull { it.getArgumentExpression() }
                .filterIsInstance()
                .map { it.text }
                .filter { it in usedLambdaParameterNames }
                .toSet()

            for (parameterName in usedLambdaParameterNames - keyedLambdaParameterNames) {
                val parameter = lambdaParameters[parameterName]!!
                emitter.report(parameter, LambdaUsedInRestartableEffect)
            }
        }
    }

    companion object {
        val LambdaUsedInRestartableEffect = """
            Lambda parameters in a @Composable that are referenced directly inside of restarting effects can cause issues or unpredictable behavior.

            If restarting the effect is ok, you can add the reference to this parameter as a key in that effect, so when the parameter changes, a new effect is created.
            However, if the effect is not to be restarted, you will need to use `rememberUpdatedState` on the parameter and use its result in the effect.

            See https://mrmans0n.github.io/compose-rules/rules/#be-mindful-of-the-arguments-you-use-inside-of-a-restarting-effect for more information.
        """.trimIndent()
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy