JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.17k stars 1.11k forks source link

Is there a component similar to the ErrorBoundary in React in Compose? #2582

Open ShirasawaSama opened 1 year ago

ShirasawaSama commented 1 year ago

I need to load third-party codes while building my UI, but the third-party code may throw exceptions during the composing. Then the whole program crashes.

So I'm wondering if there is an ErrorBoundary in Compose similar to the one in React to implement child component exception catching?

I tried the following code:

@Composable
fun ErrorBoundary(content: @Composable () -> Unit) {
    try {
        content()
    } catch (e: Throwable) {
        e.printStackTrace()
    }
}

But I got Try catch is not supported around composable function invocations.

Then I try to use the following very hacks code:

@Composable
@Suppress("ILLEGAL_TRY_CATCH_AROUND_COMPOSABLE", "INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
fun ErrorBoundary(content: @Composable () -> Unit) {
    val stack = currentComposer::class.java.getDeclaredField("pendingStack").apply { isAccessible = true }
    val curStack = stack.get(currentComposer)
    val backing = androidx.compose.runtime.Stack::class.java.getDeclaredField("backing").apply { isAccessible = true }
    val curBacking = ArrayList(backing.get(curStack) as ArrayList<*>)
    try {
        content()
    } catch (e: Throwable) {
        e.printStackTrace()
        backing.set(curStack, curBacking)
    }
}

@Composable
fun Test() {
    ErrorBoundary {
        Text("Test")
        throw RuntimeException("Test Error!")
        Text("Error")
    }
}

@Composable
fun App() { Test() }

However, if the exception occurs in a deeper component or there is a SideEffect, the above code will not work.

Because my program will load third-party plugins as an extension, I cannot control all third-party code.

I have also tried the following code, but if an exception occurs in Canvas, the whole program will still exit.

window.exceptionHandler = WindowExceptionHandler { it.printStackTrace() }
ShirasawaSama commented 1 year ago

@dima-avdeev-jb Hello, this issue is not only for the web platform, but also for the desktop/JVM and android/ios.

dima-avdeev-jb commented 1 year ago

We don't have such a component. And it's hard to implement. But, you may provide a special interface and wrap third party libraries.

For example:

interface IntegrateLibrary {
    val networkContext: CoroutineContext
    fun <T> tryCatchNonComposableLogic(logic: () -> T): T?
}

@Composable
fun IntegrateThirdPartyLibrary(libraryContent: @Composable IntegrateLibrary.() -> Unit) {
    val errorLogs: MutableState<List<String>> = remember { mutableStateOf(emptyList()) }
    if (errorLogs.value.isNotEmpty()) {
        Text("Error occurs:\n ${errorLogs.value.joinToString("\n")}")
    } else {
        val libraryContext = object : IntegrateLibrary {
            override val networkContext: CoroutineContext =
                Dispatchers.IO + CoroutineExceptionHandler { context, throwable ->
                    errorLogs.value = errorLogs.value + throwable.toString()
                }
            override fun <T> tryCatchNonComposableLogic(logic: () -> T): T? {
                try {
                    return logic()
                } catch (throwable: Throwable) {
                    errorLogs.value = errorLogs.value + throwable.toString()
                    return null
                }
            }
        }
        libraryContext.libraryContent()
    }
}

@Composable
fun Usage() {
    IntegrateThirdPartyLibrary {
        val possibleFailedResult = tryCatchNonComposableLogic {
            1 / Random.nextInt(0, 2)
        }
        LaunchedEffect(Unit) {
            withContext(networkContext) {
                // Your network request
            }
        }
    }
}
ShirasawaSama commented 1 year ago

@dima-avdeev-jb Thank you very much for your detailed reply.

Yes, I know that exceptions in the NON composing phase can be caught directly.

However, in a large workstation type program, exceptions generated by third-party plugins are possible at any stage.

If a third party can easily interrupt the entire program, the robustness of the program is too bad.

Imagine that if a jetbrains IDEA loads a plugin, it divides by 0 (in UI), and then the entire program exits without saving the user's operation.

But I wonder if it is possible to create multiple composer instances to interrupt local components (Not the whole program)? Is it possible to wrap by the SwingPanel outside?

dima-avdeev-jb commented 1 year ago

Do you need this only on Compose Desktop with JVM? If yes, then you can use ClassLoader feature to run potentially unsafe code. And you can create every loaded component with Compose in separate Swing panels. (https://github.com/JetBrains/compose-jb/blob/master/tutorials/Swing_Integration/README.md#using-composepanel) But in this case, you can't use your third party components right inside Composable functions.

ShirasawaSama commented 1 year ago

@dima-avdeev-jb Is this your suggested code? I've tried it before, but it still interrupts the whole program.

SwingPanel(Color.Red, { ComposePanel().apply {
    setContent {
        Text("Test")
        Row {
            Text("Test2")
            Box {
                throw RuntimeException("Test error!")
            }
        }
    }
} }, Modifier.fillMaxWidth())

I don't know if I can create an exclusive composer instance for a swing panel in this case.

dima-avdeev-jb commented 1 year ago

You can load this SwingPanel with ClassLoader for isolation. Do you need this behaviour only for Desktop JVM?

ShirasawaSama commented 1 year ago

Thank you very much. I will try the method you provided later today.

For now, my app is only running on JVM and may be available on Android in a few months.

However, in a word, I think one exception can interrupt the whole program, which will reduce the stability of the program. Especially for workstation software.

dima-avdeev-jb commented 1 year ago

It's harder to use ClassLoader on Android for isolation. Maybe not possible.

ShirasawaSama commented 1 year ago

Can this be achieved by providing a method for manually creating and destroying compose instances? For example, multiple react instances can be created on a single page in the web.

dima-avdeev-jb commented 1 year ago

On Android, you can control the lifecycle of Compose inside ComposeView. And manually create them. https://developer.android.com/jetpack/compose/interop/interop-apis

ShirasawaSama commented 1 year ago

Can you provide a code sample of using ClassLoader to call a compose function?

dima-avdeev-jb commented 1 year ago

I will try to do it later. But, with ClassLoader, it is not possible to directly call Composable functions. It's possible to create new Swing panels with precompiled Composable functions inside.

ShirasawaSama commented 1 year ago

Ok, thank you.

ShirasawaSama commented 1 year ago

@dima-avdeev-jb Hello, is there any progress?

dima-avdeev-jb commented 1 year ago

@ShirasawaSama Sorry, I don't have enough time for now. I will try to make a sample after the KotlinConf event. Can you please ping me after 15th April?

ShirasawaSama commented 1 year ago

@ShirasawaSama Sorry, I don't have enough time for now. I will try to make a sample after the KotlinConf event. Can you please ping me after 15th April?

Ok. And thank you very much for your reply

pjBooms commented 1 year ago

But I wonder if it is possible to create multiple composer instances to interrupt local components

I believe that for a proper isolation, we should provide a way to create multiple composer instances.

ShirasawaSama commented 1 year ago

@dima-avdeev-jb Hello, I would like to ask if you have time to deal with this issue?

dima-avdeev-jb commented 1 year ago

Yes, I will try to make a sample on this week

dima-avdeev-jb commented 1 year ago

I made a sample here: https://github.com/dima-avdeev-jb/compose-with-classloader

ShirasawaSama commented 1 year ago

@dima-avdeev-jb Thank you very much for the sample code, I will test and use it soon.

But for me, I'd rather have a way to handle and recover from local component errors.

Skaldebane commented 4 months ago

Hi there!

I also have a use-case for this, where a native try-catch mechanism will be super helpful.

So I have a font previewing app, where users can select any font file from their device, and I apply it to a BasicTextField.

I do some validation if the font is valid at all (using a native library), and then also validate if Android can open it at all (because sometimes, I noticed that Android doesn't support some fonts. Same with desktop) using FontFamilyResolver.resolve() in a try-catch.

However, despite all of these measures before changing the fontFamily state of the BasicTextField, I still see crashes happening in production due to some internal code in BasicTextField crashing (seemingly FontFamilyResolver.resolve is successful, but actually loading some fonts causes other issues).

This causes the entire app to crash, and there's no way for me to catch such issues and properly inform the user that the font is unsupported.

I'm not sure, but this could also be useful when Compose Runtime / Compiler are used for data libraries (e.g. Molecule), but I don't know much about that.

I remember some Google employee saying this was a planned thing for the future, but there's basically no news about it.

dima-avdeev-jb commented 4 months ago

@Skaldebane Thanks for your use case!