Skip to content

add render-time and async node error recovery pattern#802

Open
tmarmer wants to merge 45 commits intoerror-controllerfrom
render-error-handling
Open

add render-time and async node error recovery pattern#802
tmarmer wants to merge 45 commits intoerror-controllerfrom
render-error-handling

Conversation

@tmarmer
Copy link
Copy Markdown
Contributor

@tmarmer tmarmer commented Feb 12, 2026

  • Update captureError to fail the player state if no error transition is available.
  • Implement render-time error recovery functionality
    • iOS: Errors are handled during deserialization of asset data. Asset render functions do not support throwing so actual render-time issues do not need to be caught
    • Android: Errors are caught for each asset type and bubble up according to their needs.
      • RenderableAsset: Bubbles up to PlayerFragment error handling which calls PlayerViewModel.fail which now uses the error controller.
      • SuspendableAsset: Errors caught in the coroutine exception handler are sent to the error controller (except for cancellation exceptions)
      • ComposableAsset: Errors thrown during deserialization are sent to the error controller.
    • React: Top level ErrorBoundary on react player catches and sends errors to the ErrorController.
  • Add PlayerErrorMetadata interface for errors to implement to include info required for the error controller to capture errors
  • Add errors for specific areas where it can be brought to the error controller
    • AssetRenderError updated to include metadata required for the error controller
    • ResolverError added for errors thrown during computeTree
  • Update async-node plugin to make use of the above to call its onAsyncNodeError hook and replace broken content
  • Update JSValueError to support serialization with JSValue. Update ErrorState to use JSValueError
  • Update J2V8 .toInvokable to fix an issue where functions returning arrays of objects would deserialize those objects into a V8Node even if a deserializationStrategy was available for it.

Change Type (required)

Indicate the type of change your pull request is:

  • patch
  • minor
  • major
  • N/A

Does your PR have any documentation updates?

  • Updated docs
  • No Update needed
  • Unable to update docs

Release Notes

  • Update AsyncNodePlugin's onAsyncNodeError hook to depend on the ErrorController's onError hook for finding errors.
  • Capture render time errors using ErrorController.captureError and allow for error recovery on all platforms.
  • Add PlayerErrorMetadata interface across platforms and ensure serialization of errors keeps additional metadata.
    • When throwing an error that matches this interface, the ErrorController.captureError will prefer data from the error object when calling the onError hook.
📦 Published PR as canary version: 0.15.1--canary.802.31569

Try this version out locally by upgrading relevant packages to 0.15.1--canary.802.31569

@tmarmer tmarmer requested review from a team as code owners February 12, 2026 22:16
@codecov
Copy link
Copy Markdown

codecov bot commented Feb 13, 2026

Codecov Report

❌ Patch coverage is 94.45946% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.36%. Comparing base (07bcf2a) to head (90dad45).

Files with missing lines Patch % Lines
.../com/intuit/playerui/core/error/ErrorController.kt 66.66% 2 Missing and 5 partials ⚠️
plugins/async-node/core/src/index.ts 95.39% 7 Missing ⚠️
...e/serialization/serializers/ThrowableSerializer.kt 83.87% 3 Missing and 2 partials ⚠️
core/player/src/view/resolver/index.ts 93.65% 4 Missing ⚠️
react/player/src/player.tsx 95.87% 4 Missing ⚠️
.../com/intuit/playerui/core/player/HeadlessPlayer.kt 0.00% 3 Missing ⚠️
...e-assets/core/src/plugins/error-recovery-plugin.ts 90.32% 3 Missing ⚠️
core/player/src/controllers/view/controller.ts 87.50% 2 Missing ⚠️
...gins/async-node/core/src/utils/getNodeFromError.ts 91.30% 2 Missing ⚠️
core/player/src/controllers/error/controller.ts 95.83% 1 Missing ⚠️
... and 3 more
Additional details and impacted files
@@                 Coverage Diff                  @@
##           error-controller     #802      +/-   ##
====================================================
+ Coverage             86.02%   86.36%   +0.34%     
====================================================
  Files                   513      513              
  Lines                 23566    24029     +463     
  Branches               2703     2828     +125     
====================================================
+ Hits                  20272    20753     +481     
+ Misses                 2956     2942      -14     
+ Partials                338      334       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 13, 2026

Bundle Report

Changes will increase total bundle size by 123.04kB (2.12%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
plugins/beacon/core 422.66kB 5.2kB (1.24%) ⬆️
plugins/check-path/core 441.06kB 5.2kB (1.19%) ⬆️
plugins/stage-revert-data/core 404.68kB 5.2kB (1.3%) ⬆️
react/subscribe 13.68kB 2.04kB (17.56%) ⬆️
plugins/reference-assets/core 503.74kB 25.04kB (5.23%) ⬆️
react/player 83.71kB 10.36kB (14.12%) ⬆️
plugins/common-expressions/core 426.92kB 5.2kB (1.23%) ⬆️
plugins/markdown/core 681.75kB 5.21kB (0.77%) ⬆️
plugins/common-types/core 501.24kB 5.2kB (1.05%) ⬆️
plugins/metrics/core 459.22kB 5.2kB (1.14%) ⬆️
plugins/async-node/core 502.71kB 29.19kB (6.16%) ⬆️
core/player 1.02MB 20.03kB (2.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: plugins/common-types/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
CommonTypesPlugin.native.js 5.2kB 430.07kB 1.22%
view changes for bundle: plugins/check-path/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
CheckPathPlugin.native.js 5.2kB 411.58kB 1.28%
view changes for bundle: react/player

Assets Changed:

Asset Name Size Change Total Size Change (%)
cjs/index.cjs 3.65kB 30.35kB 13.65% ⚠️
index.legacy-esm.js 3.36kB 26.68kB 14.39% ⚠️
index.mjs 3.36kB 26.68kB 14.39% ⚠️
view changes for bundle: plugins/beacon/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
BeaconPlugin.native.js 5.2kB 408.19kB 1.29%
view changes for bundle: plugins/async-node/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
AsyncNodePlugin.native.js 14.41kB 440.09kB 3.39%
cjs/index.cjs 4.99kB 22.39kB 28.66% ⚠️
index.legacy-esm.js 4.89kB 20.12kB 32.16% ⚠️
index.mjs 4.89kB 20.12kB 32.16% ⚠️
view changes for bundle: react/subscribe

Assets Changed:

Asset Name Size Change Total Size Change (%)
cjs/index.cjs 739 bytes 5.74kB 14.77% ⚠️
index.legacy-esm.js 652 bytes 3.97kB 19.66% ⚠️
index.mjs 652 bytes 3.97kB 19.66% ⚠️
view changes for bundle: plugins/common-expressions/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
CommonExpressionsPlugin.native.js 5.2kB 405.67kB 1.3%
view changes for bundle: plugins/reference-assets/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
ReferenceAssetsPlugin.native.js 18.08kB 466.88kB 4.03%
cjs/index.cjs 2.33kB 13.52kB 20.81% ⚠️
index.legacy-esm.js 2.31kB 11.67kB 24.73% ⚠️
index.mjs 2.31kB 11.67kB 24.73% ⚠️
view changes for bundle: plugins/markdown/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
MarkdownPlugin.native.js 5.21kB 656.59kB 0.8%
view changes for bundle: plugins/stage-revert-data/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
StageRevertDataPlugin.native.js 5.2kB 397.86kB 1.32%
view changes for bundle: core/player

Assets Changed:

Asset Name Size Change Total Size Change (%)
Player.native.js 6.12kB 424.8kB 1.46%
cjs/index.cjs 4.72kB 202.59kB 2.39%
index.legacy-esm.js 4.59kB 195.86kB 2.4%
index.mjs 4.59kB 195.86kB 2.4%
view changes for bundle: plugins/metrics/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
MetricsPlugin.native.js 5.2kB 426.88kB 1.23%

@tmarmer
Copy link
Copy Markdown
Contributor Author

tmarmer commented Feb 13, 2026

/canary

intuit-svc added a commit to player-ui/player-ui.github.io that referenced this pull request Feb 13, 2026
@intuit-svc
Copy link
Copy Markdown
Contributor

Build Preview

Your PR was deployed by CircleCI #31569 on Fri, 13 Feb 2026 14:55:49 GMT with this version:

0.15.1--canary.802.31569

📖 Docs (View site)

@tmarmer tmarmer requested a review from a team as a code owner February 24, 2026 21:45
@tmarmer tmarmer marked this pull request as draft March 10, 2026 18:57
Comment on lines +45 to +60
/** Returns a function to be used as the `replacer` for JSON.stringify that tracks and ignores circular references. */
const makeJsonStringifyReplacer = (): ReplacerFunction => {
const cache = new Set();
return (_: string, value: any) => {
if (typeof value === "object" && value !== null) {
if (cache.has(value)) {
// Circular reference found, discard key
return "[CIRCULAR]";
}
// Store value in our collection
cache.add(value);
}
return value;
};
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need to do this? Shouldn't internal Player state be serializable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The issue here stems from the ResolverError adding in the failed node in its metadata. We need a better solution but this was put in place to allow the metadata to get logged without throwing errors since the node itself has circular references.

If we don't want the node logged or set in the dataController as part of captureError we either need to not include the metadata in logs/data or we need another mechanism to identify resolver nodes with to avoid sending the whole object to keep its reference.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So this is so that the error includes the originating AST node, gotcha. I think a reference should be able to be stored fine in the Data Controller without serialization right? For logging can we just reference the asset ID?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If the error controller was aware of the metadata being logged we could, but because any error with any metadata could pass through it, we can't identify when we're getting something like a Node or a simpler object. That and sometimes the originating Node being logged might be an async node or something else that generated the asset.

Comment on lines +204 to +211
export const ResolverStages = {
ResolveOptions: "resolve-options",
SkipResolve: "skip-resolve",
BeforeResolve: "before-resolve",
Resolve: "resolve",
AfterResolve: "after-resolve",
AfterNodeUpdate: "after-node-update",
} as const;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there a way we can automate this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Couldn't find a way to automatically form this object, but simplified the types here to use an enum. If you have any ideas here I'm open to try something else

@tmarmer
Copy link
Copy Markdown
Contributor Author

tmarmer commented Mar 31, 2026

iOS changes looks good, do we have any examples/mocks in the demo app with errorTransitions or error state structure, if not maybe can add them in later?

@nancywu1 The only example I add here is an update to the chat-ui mock to show how the error recovery works for async nodes since that's primarily what this PR is for. I know @cehan-Chloe added docs for some of this stuff but I'm unsure if the base error-controller branch has any mocks for this case.

@cehan-Chloe
Copy link
Copy Markdown
Contributor

iOS changes looks good, do we have any examples/mocks in the demo app with errorTransitions or error state structure, if not maybe can add them in later?

@nancywu1 The only example I add here is an update to the chat-ui mock to show how the error recovery works for async nodes since that's primarily what this PR is for. I know @cehan-Chloe added docs for some of this stuff but I'm unsure if the base error-controller branch has any mocks for this case.

I added doc and tests for error controller. There weren't specific storybook/demo app examples for controllers, but since this one added a navigation errorState, so may worth adding it to storybook like managedPlayer. Thought?

}

if (exception is AssetRenderException) {
exception.assetParentPath += assetContext
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i'm a bit late here, this looks like it's supposed to bubble up from the asset level that threw the original error to show the full path? Does this mean the final message is going to be in reverse order from the originating asset?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The list will be in reverse-order of the actual view tree, but this is used to create the error message here. The reverse order lets us list the most relevant asset first and list where it and its parents were found in.

styles: AssetStyle? = null,
tag: String? = null,
) {
val assetTag = tag ?: asset.id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what's this tag logic change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The code here was originally calling withTag on the asset context even if no tag was provided. This change was meant to prevent that, but I didn't want to change the .testTag call to avoid breaking tests. This was something @sugarmanz and I noticed when I was testing earlier iterations where the I was matching assets based on the assetContext.id but was getting the asset id duplicated in the string. This is out of scope of the PR though so if there are issues with this I can revert it and we can look into this more in depth another time.

)
Assertions.assertEquals("ErrorType", exception.type)
Assertions.assertEquals(ErrorSeverity.ERROR, exception.severity)
Assertions.assertEquals(mapOf("testProperty" to "testValue"), exception.metadata)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

following previous comment, i'm a bit surprised this is passing, @sugarmanz what am i missing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As per my other comment, I think GenericSerializer was picking the MapSerializer for me. I've updated ThrowSerializer to use MapSerializer instead.

guard
let flow = value?.objectForKeyedSubscript("flow"),
let message = value?.objectForKeyedSubscript("error")?.objectForKeyedSubscript("message")?.toString()
let err = value?.objectForKeyedSubscript("error")
Copy link
Copy Markdown
Contributor

@JunDangIntuit JunDangIntuit Apr 2, 2026

Choose a reason for hiding this comment

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

Could we put all keys in an enum, not string literals in the code. I've noticed this pattern across several iOS files (CompletedState.swift, NavigationStates.swift, PlayerControllers, etc.): like objectForKeyedSubscript("flowResult"), objectForKeyedSubscript("controllers") etc. A typo in any of these would silently return undefined with no compiler warning. If it's out of scope for this PR, I'd be happy to take on a follow-up PR to address it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll make the keys for this object into an enum, but could you make a github issue to follow up and remove this across the repo? I think it's a good practice we should try to keep up with

return jsError.getJSErrorObject()
}

let errObj = objectForKeyedSubscript("Error").construct(withArguments: [error.jsDescription])
Copy link
Copy Markdown
Contributor

@JunDangIntuit JunDangIntuit Apr 2, 2026

Choose a reason for hiding this comment

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

The key "Error" in objectForKeyedSubscript("Error") (the JS global Error constructor) vs "error" in objectForKeyedSubscript("error") (a property on the state object) are easy to confuse at a glance. As a newcomer to the codebase, I had to look them up to confirm they're intentionally different.

Would it be worth extracting JS keys into named constants? For example:

private enum JSGlobal {
static let error = "Error" // JS Error constructor
}
This way, objectForKeyedSubscript(JSGlobal.error).construct(...) makes the intent immediately clear without needing to know the JS convention. Not a blocker — just a readability thought for future contributors.

Copy link
Copy Markdown
Contributor Author

@tmarmer tmarmer Apr 2, 2026

Choose a reason for hiding this comment

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

I like this idea a lot! I'm wondering what you think of taking it maybe one step further and adding a utility to shortcut constructing known JS classes from the JSContext. Thinking something like:

internal enum JSClass: String {
    case Error = "Error"
}

internal extension JSContext {
    func getJSClass(_ name: JSClass) -> JSValue? {
        objectForKeyedSubscript(name)
    }

    func constructClass(_ name: JSClass, withArguments: [Any]?) -> JSValue? {
        getClass(name).construct(withArguments: withArguments)
    }
}

It's definitely on the simpler side of utilities but imo it helps clear up the intent of the enum since I don't think it's immediately obvious that the use is meant for objectForKeyedSubscript on the JSContext itself.


public protocol ErrorWithMetadata : Error {
var hasMetadata: Bool { get }
var type: String { get }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: since errorType is a String to allow plugin-defined custom types, consider adding a doc comment on captureError(errorType:) noting that callers should prefer ErrorTypes constants when applicable, but custom strings are accepted for plugin-specific error types.

return type
default:
return "UNKNOWN"
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"UNKNOWN" for non-metadata cases — should this be an ErrorTypes constant? like add ErrorTypes.unknown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the "UNKNOWN" case. The fallback now is to use an empty string to represent not having a known error type. This should only happen for errors that don't follow our shared interface so I think it's okay to keep this out of ErrorTypes enum imo.

Copy link
Copy Markdown
Member

@KVSRoyal KVSRoyal left a comment

Choose a reason for hiding this comment

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

Just a few really small comments. Otherwise lgtm

Comment on lines +13 to +16
public enum TryCatchResultKeys {
static let success = "success"
static let result = "result"
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  1. Nit: Could this conform to String just like the JSClass enum. It's also weird this enum doesn't have any cases 😅 so it would be a good change in that regard too. E.g.
public enum TryCatchResultKeys: String {
    case success, result
}
  1. Does this need to be public? They seem like an internal implementation detail. If they don't need to be public, can we remove the access modifier (will default to internal)?

Comment on lines 75 to 80
public enum JSKeys {
static let message = "message"
static let type = "type"
static let severity = "severity"
static let metadata = "metadata"
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same comment here about the enum. Can this conform to String and have cases?


extension JSValueError: ErrorWithMetadata {
public var hasMetadata: Bool {
!type.isEmpty
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is it correct for hasMetadata to check type is empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Because the ErrorWithMetadata requires the type property, it's the simplest identifier we have for errors coming from JS. I could alternatively make hasMetadata a property and set its value during init based on the existence of type coming from JS. This would allow empty strings as a valid type value and make the condition a little more. Would that work better here?

}

public protocol ErrorWithMetadata : Error {
var hasMetadata: Bool { get }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hasMetadata is purely derivable from metadata != nil. It should not to be a protocol requirement

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe the property name is incorrect. I need a way (at least on the JSValueError to declare whether or not the error actually conforms to the shared interface error with metadata interface. The only required property for that interface is the type property (which is why I check if it's empty on JSValueError) but the others are optional. I figured having this extra property would also allow enum errors to only have the additional data supported on some error types.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think hasMetadata should not be in the protocol. you could probably do this:

func error<E>(for error: E) -> JSValue? where E: Error, E: JSConvertibleError {
      if let jsValueError = error as? JSValueError {
          return jsValueError.originalJSError
      }
      
      let errObj = constructClass(.Error, withArguments: [error.jsDescription])
      if let errorWithMetadata = error as? ErrorWithMetadata, let err = errObj { /// this is checking if it is nil
          if !errorWithMetadata.type.isEmpty {  /// this is checking if the type is empty
              err.setValue(errorWithMetadata.type, forProperty: JSValueError.JSKeys.type)
          }
          if let severity = errorWithMetadata.severity {
              err.setValue(severity.rawValue, forProperty: JSValueError.JSKeys.severity)
          }
          if let metadata = errorWithMetadata.metadata {
              err.setValue(metadata, forProperty: JSValueError.JSKeys.metadata)
          }
      }

A separate flag that can drift out of sync with the actual properties. In another place

 extension AssetRenderError: ErrorWithMetadata {
    public var hasMetadata: Bool {
        true
    }

it's boilerplate forced by the protocol that adds no value. The protocol shouldn't require conformers to implement a property that's always a constant or always derivable from other properties already in the protocol.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Alright, makes sense to me. I think I could skip the isEmpty check within the error function since it really is just an edge case for JS errors coming from the core layer. In those cases the first check that returns the original object anyway means we don't even really need to care when serializing the error since anybody else conforming to that protocol should be provided some kind of recognizable string value anyway. I'll still leave a isErrorWithMetadata on JSValueError itself in case we need some way to tell whether or not there is any meaningful metadata on the error (like if somebody is tapping into the onError hook on the error controller)

This class represents a JS Hook with the ability to return a result. This can work for a Waterfall or Bail hook.
*/
public class BailHook<T>: BaseJSHook where T: CreatedFromJSValue {
public class HookWithResult<T, R>: BaseJSHook where T: CreatedFromJSValue, R: Encodable {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

May you share the reason to add Encodable here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This file is for tapable hooks that interact with the JS layer. It's possible that the result is read by some core JS code so unless I misunderstand how the integration works, it would need to be Encodable in order to actually work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you might not need Encodable. Encodable implies encode(to:) is used for JS serialization, but it isn't — JavaScriptCore bridges via Obj-C (NSNumber, NSDictionary, etc.).
aka: When a @convention(block) closure returns a value to JavaScriptCore, it doesn't know about Swift's Codable system. It uses Objective-C bridging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants