Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package com.intuit.playerui.android.asset

import com.intuit.playerui.android.AssetContext
import com.intuit.playerui.core.bridge.serialization.serializers.ThrowableSerializer
import com.intuit.playerui.core.error.ErrorSeverity
import com.intuit.playerui.core.error.ErrorTypes
import com.intuit.playerui.core.player.PlayerException
import com.intuit.playerui.core.player.PlayerExceptionMetadata
import kotlinx.serialization.Serializable

class AssetRenderException : PlayerException {
@Serializable(ThrowableSerializer::class)
class AssetRenderException :
PlayerException,
PlayerExceptionMetadata {
private var _assetParentPath: List<AssetContext> = emptyList()
var assetParentPath: List<AssetContext>
get() = _assetParentPath
Expand All @@ -19,6 +27,10 @@ class AssetRenderException : PlayerException {
val initialMessage: String
override var message: String = ""

override val type: String = ErrorTypes.RENDER.value
override val severity: ErrorSeverity = ErrorSeverity.ERROR
override val metadata: Map<String, Any?>

internal constructor(rootAsset: AssetContext, message: String, exception: Throwable? = null) : super(message, exception) {
val errorMessage = if (exception == null) {
message
Expand All @@ -28,6 +40,10 @@ Caused by: ${exception.message}
""".trimMargin()
}
initialMessage = "$errorMessage\nException occurred in asset with id '${rootAsset.id}' of type '${rootAsset.type}"
this.message = initialMessage
this.rootAsset = rootAsset
this.metadata = mapOf(
"assetId" to this.rootAsset.asset.id,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,22 @@ public abstract class SuspendableAsset<Data>(
}

private suspend fun doInitView() = withContext(Dispatchers.Default) {
initView(getData()).apply { setTag(R.bool.view_hydrated, false) }
// TODO: Centralize some of this error handling so that it can be repeated easily.
try {
initView(getData()).apply { setTag(R.bool.view_hydrated, false) }
} catch (exception: Throwable) {
// ignore cancellation exceptions because those are used to rehydrate the view
if (exception is CancellationException) {
throw exception
}

if (exception is AssetRenderException) {
exception.assetParentPath += assetContext
throw exception
} else {
throw AssetRenderException(assetContext, "Failed to render asset", exception)
}
}
}

// To be launched in Dispatchers.Main
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.viewinterop.AndroidView
import com.intuit.playerui.android.AssetContext
import com.intuit.playerui.android.asset.AssetRenderException
import com.intuit.playerui.android.asset.RenderableAsset
import com.intuit.playerui.android.asset.SuspendableAsset
import com.intuit.playerui.android.build
Expand All @@ -25,9 +26,11 @@ import com.intuit.playerui.android.extensions.into
import com.intuit.playerui.android.withContext
import com.intuit.playerui.android.withTag
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.player.state.inProgressState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer
import kotlin.coroutines.cancellation.CancellationException

/**
* Base class for assets that render using Jetpack Compose.
Expand All @@ -54,7 +57,18 @@ public abstract class ComposableAsset<Data>(
@Composable
public fun compose(data: Data? = null) {
val data: Data? by produceState(initialValue = data, key1 = this) {
value = getData()
try {
value = getData()
} catch (error: Throwable) {
if (error is CancellationException) {
throw error
}

player.inProgressState?.controllers?.error?.captureError(
AssetRenderException(assetContext, "Error fetching data while rendering asset. See cause for details", error),
)
null
}
}

data?.let {
Expand Down Expand Up @@ -83,9 +97,12 @@ public abstract class ComposableAsset<Data>(
styles: AssetStyle? = null,
tag: String? = null,
) {
val assetTag = tag ?: asset.id
val containerModifier = Modifier.testTag(assetTag) then modifier
assetContext.withContext(LocalContext.current).withTag(assetTag).build().run {
val containerModifier = Modifier.testTag(tag ?: asset.id) then modifier
var context = assetContext.withContext(LocalContext.current)
if (tag != null) {
context = context.withTag(tag)
}
context.build().run {
renewHydrationScope("Creating view within a ComposableAsset")
when (this) {
is ComposableAsset<*> -> CompositionLocalProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ public open class PlayerViewModel(
}

public fun fail(cause: Throwable) {
player.inProgressState?.fail(cause)
player.inProgressState?.controllers?.error?.captureError(
cause,
)
}

/** Helper to progress the [FlowManager] in within the [viewModelScope] */
Expand All @@ -224,5 +226,6 @@ public open class PlayerViewModel(
}

public inline fun PlayerViewModel.fail(message: String, cause: Throwable? = null) {
fail(PlayerException(message, cause))
val playerException = cause as? PlayerException ?: PlayerException(message, cause)
fail(playerException)
}
8 changes: 6 additions & 2 deletions codecov.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ codecov:
require_ci_to_pass: no
# Post comment if there are changes in bundle size increases more than 1Kb
comment:
require_bundle_changes: "bundle_increase"
bundle_change_threshold: "1Kb"
require_bundle_changes: "bundle_increase"
bundle_change_threshold: "1Kb"

# Ignore test utils in coverage
ignore:
- "jvm/testutils"
35 changes: 34 additions & 1 deletion core/player/src/__tests__/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Flow, NavigationFlowViewState } from "@player-ui/types";
import type { FlowController } from "../controllers";
import TrackBindingPlugin from "./helpers/binding.plugin";
import type { InProgressState } from "../types";
import { Player } from "..";
import { Player, PlayerPlugin } from "..";
import { ActionExpPlugin } from "./helpers/action-exp.plugin";

const minimal: Flow = {
Expand Down Expand Up @@ -713,3 +713,36 @@ describe("view update scheduling", () => {
});
});
});

describe("view error capturing", () => {
test("should capture errors caused during view resolution and send them to the errorController", async () => {
const errorControllerSpy = vitest.fn(() => undefined);
const viewFailurePlugin: PlayerPlugin = {
name: "ViewFailurePlugin",
apply: (player) => {
// Force resolution failures to capture in the view controller
player.hooks.view.tap("fail", (view) => {
view.hooks.resolver.tap("fail", (resolver) => {
resolver.hooks.beforeResolve.tap("fail", () => {
throw new Error("ERROR!");
});
});
});

player.hooks.errorController.tap("fail", (controller) => {
controller.hooks.onError.tap("fail", errorControllerSpy);
});
},
};

const player = new Player({ plugins: [viewFailurePlugin] });
player.start(minimal).catch(() => {});
await vitest.waitFor(() => {
expect(errorControllerSpy).toHaveBeenCalledWith(
expect.objectContaining({
cause: new Error("ERROR!"),
}),
);
});
});
});
Loading
Loading