Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
983989f
add asset error recovery via asyncNodePlugin on web and android
tmarmer Feb 12, 2026
68a9169
fix test errors
tmarmer Feb 12, 2026
7ae30a5
fix async test
tmarmer Feb 13, 2026
17ce531
comment last broken test for now
tmarmer Feb 13, 2026
0946e3b
finish ios implementation of error recovery
tmarmer Feb 24, 2026
f81c0f0
add errors with metadata to error controller on react and android
tmarmer Mar 10, 2026
76aaf30
re-enable react player test. fail player state from error controller …
tmarmer Mar 11, 2026
cf65e8b
update ResolverStage to be an enum
tmarmer Mar 11, 2026
b0b9db2
fix android build errors
tmarmer Mar 11, 2026
81dc14e
finish ios error recovery implementation
tmarmer Mar 11, 2026
06a8f7f
format kt and add ts tests
tmarmer Mar 12, 2026
163389d
fix mock package build
tmarmer Mar 12, 2026
50f552c
fix throwable serializer. Improve react state management during errors
tmarmer Mar 19, 2026
103a405
remove throwing asset. fix error severity in jvm
tmarmer Mar 19, 2026
5f6b7b7
revert delete of one jvm test. use local data model in error middlewa…
tmarmer Mar 19, 2026
8cb00ce
revert change to player config. fix swift errorcontroller tests
tmarmer Mar 20, 2026
7f4d5f0
fix swiftlint error
tmarmer Mar 20, 2026
d429e81
Update core/player/src/controllers/error/utils/isErrorWithMetadata.ts
tmarmer Mar 23, 2026
88ba90b
Update plugins/async-node/core/src/utils/getNodeFromError.ts
tmarmer Mar 23, 2026
c095a6d
Update core/player/src/controllers/error/utils/__tests__/isErrorWithM…
tmarmer Mar 23, 2026
7c3194b
make resolvererror public in player core
tmarmer Mar 23, 2026
43aca17
update reference asset plugin tests
tmarmer Mar 23, 2026
6382c75
update error middleware to export binding prefix
tmarmer Mar 23, 2026
3624db1
add tests for useSubscriber hook
tmarmer Mar 23, 2026
c67821b
fix eslint error
tmarmer Mar 24, 2026
a75ceb1
update testing for async node plugin. fix issue with recursive search…
tmarmer Mar 24, 2026
d2946fe
add react player tests for error handling
tmarmer Mar 24, 2026
702e789
add error tests for resolver changes
tmarmer Mar 24, 2026
dd4573e
fix test and lint errors
tmarmer Mar 24, 2026
37c86f4
update lockfile
tmarmer Mar 25, 2026
d48a5a5
add additional throwable serializer tests for new functionality
tmarmer Mar 25, 2026
a98ff7d
replace hasErrorTransition function with getErrorTransitionState reus…
tmarmer Mar 25, 2026
56817bf
update error docs for async node plugin
tmarmer Mar 25, 2026
73cbfe2
format kt files to fix lint issues
tmarmer Mar 25, 2026
107cbfe
ignore jvm testutils in codecov report
tmarmer Mar 25, 2026
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
2,947 changes: 2,947 additions & 0 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

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 @@ -28,6 +36,16 @@ 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
}

override val type: String
get() = ErrorTypes.RENDER
override val severity: ErrorSeverity?
get() = ErrorSeverity.ERROR
override val metadata: Map<String, Any?>?
get() = mapOf(
"assetId" to rootAsset.asset.id,
)
Comment on lines +43 to +50
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are static, just assign them:

Suggested change
override val type: String
get() = ErrorTypes.RENDER
override val severity: ErrorSeverity?
get() = ErrorSeverity.ERROR
override val metadata: Map<String, Any?>?
get() = mapOf(
"assetId" to rootAsset.asset.id,
)
override val type: String = ErrorTypes.RENDER
override val severity: ErrorSeverity? = ErrorSeverity.ERROR
override val metadata: Map<String, Any?> = mapOf(
"assetId" to 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,16 +17,20 @@ 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
import com.intuit.playerui.android.extensions.Styles
import com.intuit.playerui.android.extensions.into
import com.intuit.playerui.android.withContext
import com.intuit.playerui.android.withTag
import com.intuit.playerui.core.error.ErrorTypes
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.player.state.inProgressState
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer
import kotlin.coroutines.cancellation.CancellationException

/**
* Base class for assets that render using Jetpack Compose.
Expand All @@ -53,7 +57,19 @@ 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),
ErrorTypes.RENDER,
)
null
}
}

data?.let {
Expand Down Expand Up @@ -82,9 +98,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 @@ -8,6 +8,7 @@ import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AndroidPlayerPlugin
import com.intuit.playerui.android.asset.RenderableAsset
import com.intuit.playerui.core.bridge.runtime.Runtime
import com.intuit.playerui.core.error.ErrorTypes
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.managed.AsyncFlowIterator
import com.intuit.playerui.core.managed.AsyncIterationManager
Expand Down Expand Up @@ -207,7 +208,10 @@ public open class PlayerViewModel(
}

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

/** Helper to progress the [FlowManager] in within the [viewModelScope] */
Expand All @@ -225,5 +229,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"
41 changes: 40 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 { ErrorSeverity, ErrorTypes, Player, PlayerPlugin } from "..";
import { ActionExpPlugin } from "./helpers/action-exp.plugin";

const minimal: Flow = {
Expand Down Expand Up @@ -713,3 +713,42 @@ 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({
error: expect.objectContaining({
cause: new Error("ERROR!"),
}),
errorType: ErrorTypes.VIEW,
skipped: false,
metadata: {
node: expect.anything(),
},
severity: ErrorSeverity.ERROR,
});
});
});
});
54 changes: 53 additions & 1 deletion core/player/src/controllers/error/__tests__/controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { describe, it, beforeEach, expect, vitest } from "vitest";
import { ErrorController } from "../controller";
import { ErrorSeverity, ErrorTypes } from "../types";
import {
ErrorMetadata,
ErrorSeverity,
ErrorTypes,
PlayerErrorMetadata,
} from "../types";
import type { DataController } from "../../data/controller";
import type { FlowController } from "../../flow/controller";
import type { Logger } from "../../../logger";

/** Test class to create an error with any additional properties */
class ErrorWithProps extends Error implements PlayerErrorMetadata {
constructor(
message: string,
public type: string,
public severity?: ErrorSeverity,
public metadata?: ErrorMetadata,
) {
super(message);
}
}

describe("ErrorController", () => {
let errorController: ErrorController;
let mockDataController: DataController;
Expand Down Expand Up @@ -99,6 +116,36 @@ describe("ErrorController", () => {
}),
);
});

it("should merge options from args and error object when avaiable", () => {
const error = new ErrorWithProps(
"Message",
ErrorTypes.EXPRESSION,
ErrorSeverity.FATAL,
{ fromError: "value", overlap: "error" },
);
const err = errorController.captureError(
error,
ErrorTypes.VIEW,
ErrorSeverity.WARNING,
{
fromFunctionCall: "value",
overlap: "function",
},
);

expect(err).toStrictEqual({
skipped: false,
metadata: {
fromError: "value",
overlap: "error",
fromFunctionCall: "value",
},
severity: ErrorSeverity.FATAL,
errorType: ErrorTypes.EXPRESSION,
error,
});
});
});

describe("getCurrentError", () => {
Expand Down Expand Up @@ -247,6 +294,11 @@ describe("ErrorController", () => {
expect(observer2).not.toHaveBeenCalled(); // Execution stops after bail
// Data model should not be updated when skipped
expect(mockDataController.set).not.toHaveBeenCalled();
expect(playerError).toStrictEqual(
expect.objectContaining({
skipped: true,
}),
);
});

it("should continue to next plugin when undefined is returned", () => {
Expand Down
Loading