Skip to content

Player 1.0

Player 1.0.0 is releasing across the entire ecosystem. This is not a conventional “1.0”: Player has been running in production for years and this isn’t a first declaration of stability. Rather, it marks the point where Player and its surrounding ecosystem have converged into a cohesive whole we’re ready to treat as the foundation for future development, with a set of breaking changes consolidated here so subsequent work can proceed from a consistent base. With more breaking changes already in the pipeline, 1.0.0 gives us a defined starting point from which future majors can be planned, announced, and rolled out predictably.

Below is a detailed guide explaining all the breaking changes we have made as part of this 1.0 release. If a change pertains to only one platform, that section will be prefaced with “(Core)”, “(iOS)”, or “(Android)”. E.g. (iOS) .anyArray and .anyDictionary use AnyType.

(iOS) .anyArray and .anyDictionary use AnyType

Section titled “(iOS) .anyArray and .anyDictionary use AnyType”

The AnyType enum now conforms to Sendable protocol and is concurrency-safe. This required removing all APIs that depended on the Any type:

// Before
case anyDictionary(data: [String: Any])
case anyArray(data: [Any])
// After
case anyDictionary(data: [String: AnyType])
case anyArray(data: [AnyType])

Use AnyType cases directly instead of converting from/to Any:

// Old (removed)
let anyType = AnyType.anyDictionary(data: ["key": "value"])
let anyArray = AnyType.anyArray(data: ["key", 2])
// New (Sendable-safe)
let anyType = AnyType.anyDictionary(data: ["key": .string(data: "value")])
let anyArray = AnyType.anyArray(data: [.string(data: "key"), .number(data: 2)])

(iOS) AnyType will only decode .unknownData with AnyTypeDecodingContext

Section titled “(iOS) AnyType will only decode .unknownData with AnyTypeDecodingContext”

Users are required to provide AnyTypeDecodingContext when attempting to decode anyArray or anyDictionary. The decoder will throw if we need the context but one is not provided. As a side effect of this, .unknownData will only be returned when a context is provided. For example:

// Before, decoding data = "null"
let anyType = try? JSONDecoder() // no context
.decode(AnyType.self, from: data) // This will return .unknownData
// After, decoding data = "null"
let anyType = try? JSONDecoder() // no context
.decode(AnyType.self, from: data) // This will throw an error
let anyType = try? AnyTypeDecodingContext(rawData: data) // with context
.inject(to: JSONDecoder())
.decode(AnyType.self, from: data) // This will return .unknownData

(iOS) AnyTypeDecodingContext.objectFor is no longer public.

Section titled “(iOS) AnyTypeDecodingContext.objectFor is no longer public.”

All decoding of AnyType is now handled internally. So AnyTypeDecodingContext.objectFor(path: [CodingKey]) throws -> Any is no longer exposed to users.

All platforms now have a user-facing FlowManager with an aligned API.

Android previously aliased AsyncIterationManager to FlowManager. However, Android’s actual FlowManager equivalent was AsyncIterator. AsyncIterator with appropriate types is now aliased as FlowManager to match the other platforms.

Migration: In Kotlin, use the FlowManager type with PlayerViewModel instead of any AsyncIterator. E.g.

// Before
PlayerViewModel(flows = someAsyncFlowIterator)
// After
PlayerViewModel(manager: someFlowManager)
  • The parameter of next is now named result across all platforms.
  • Swift and Kotlin now both return the simpler String? (null/nil = done); React returns an object object to fit the JS Iterator protocol
PlatformBeforeAfter
React

next: (previousValue?: CompletedState) => Promise<FinalState | NextState<Flow>>

next: (result?: CompletedState) => Promise<FinalState | NextState>


Migration: Change the function signature to match.

Swift

func next(_ state: CompletedState?) async throws -> NextState

func next(result: CompletedState?) async throws -> String?


Migration: Instead of .flow(someString), return the String directly. Instead of .finished, return nil.

Kotlin

suspend fun next(result: CompletedState?): String?

Unchanged

This API is now the same across all platforms. It accepts an optional InProgressState.

PlatformBeforeAfter
React
terminate?: (data?: FlowResult[“data”]) => void
terminate?: (state?: InProgressState) => void

Migration: Call terminate with the InProgressState instead of the flow data. This may look like:

const state = reactPlayer.player.getState();
if (playerState?.status === "in-progress") {
  flowManager.terminate(playerState)
}
Swift
func terminate(state: InProgressState?)
Unchanged
Kotlin
suspend fun terminate()

(on AsyncFlowIterator)

suspend fun terminate(data: InProgressState? = null)

(on FlowManager)



Migration: Call terminate with the InProgressState. This may look like:

// In PlayerViewModel.kt:
val playerState = player.inProgressState
flowManager.terminate(playerState)

The generic AsyncIterator now accepts 3 types instead of 2:

public interface AsyncIterator<Item : Any, Result : Any, Data : Any>

The 3rd type defines the parameter for terminate:

public suspend fun terminate(data: Data? = null) {}

Migration: Provide the Data type appropriate for your use case. If terminate will always be called with nill, use Nothing.

(iOS/Android) CommonTypesPlugin, CommonExpressionsPlugin, ComputedPropertiesPlugin, and StageRevertDataPlugin removed

Section titled “(iOS/Android) CommonTypesPlugin, CommonExpressionsPlugin, ComputedPropertiesPlugin, and StageRevertDataPlugin removed”

These plugins are now core-only. They operate at the shared JS core layer and should not be registered per-platform.

Remove any direct registration of these plugins in your iOS or Android code:

// Before — no longer needed
plugins: [
CommonTypesPlugin(),
CommonExpressionsPlugin(),
ComputedPropertiesPlugin(),
StageRevertDataPlugin(),
]
// Before — no longer needed
override val plugins = listOf(
CommonTypesPlugin(),
ReferenceAssetsPlugin(),
)

Instead, register these plugins once in the shared core layer of your Player plugin, following the process described in the corresponding docs:

Since CocoaPods is in maintenance mode, we are dropping support in favour of using Swift Package Manager (SPM) only. Please leverage the Swift Package, e.g.

// In your Package.swift
.dependencies: [
.package(url: "https://github.com/player-ui/playerui-swift-package.git", from: "1.0.0"),
],
targets: [
.target(
name: "SomeExampleTarget",
dependencies: [
.product(name: "PlayerUI", package: "playerui-swift-package"),
],
),
]

(iOS) ErrorState.error is now JSValueError instead of String

Section titled “(iOS) ErrorState.error is now JSValueError instead of String”

ErrorState.error previously held the raw error message as a String. It is now a JSValueError struct that exposes the full error metadata from the JS layer:

// Before
let message: String = errorState.error
// After
let message: String = errorState.error.message
let type: String = errorState.error.type
let severity: ErrorSeverity? = errorState.error.severity
let metadata: [String: Any]? = errorState.error.metadata

Migration: Replace direct string usage of errorState.error with errorState.error.message.

ExternalActionPlugin renamed to ExternalStatePlugin with new handler API

Section titled “ExternalActionPlugin renamed to ExternalStatePlugin with new handler API”

The plugin has been renamed from ExternalActionPlugin to ExternalStatePlugin. The name ExternalState better reflects what the plugin does — it handles EXTERNAL navigation states — and is now used consistently across all platforms.

The handler API has changed. Instead of a single function that receives all external states, you now provide an array of handlers. Each handler requires a ref (matching the external state’s ref field) and a handlerFunction. An optional match object can be provided to match on additional state properties beyond ref:

// Before
new ExternalActionPlugin((state, options) => {
if (state.ref === "my-action") {
return "Next";
}
});
// After
new ExternalStatePlugin([
{ ref: "my-action", handlerFunction: (state, options) => "Next" },
{ ref: "other-action", handlerFunction: (state, options) => "Prev" },
]);

Handlers are matched against the external state using partial object matching on ref and any additional properties in match. When multiple handlers could match a state, the most specific one wins (the one with the most matching properties).

This allows you to register a general handler for a ref and a more specific one for a particular variant:

new ExternalStatePlugin([
// Matches any state with ref: "my-action"
{ ref: "my-action", handlerFunction: () => "default" },
// Takes precedence when type: "special" is also present
{
ref: "my-action",
match: { type: "special" },
handlerFunction: () => "special",
},
]);

Overriding handlers is now supported. When multiple ExternalStatePlugin instances register a handler for the same state, the last registered handler wins. A debug log is emitted when a handler is replaced, so you can tell when an override is happening.

To add more handlers after initial setup, register a new ExternalStatePlugin instance. Plugins are typically grouped together and easy to find by name. This makes it straightforward to determine the override order.

We considered allowing more handlers to be added to an ExternalStatePlugin after creation, however, this would make it more difficult to figure out the override order. So we do not support that.

For now, If no handler matches an external state, the Player remains on that state and no transition occurs. Ensure every EXTERNAL state in your flow has a registered handler.

The tooling ecosystem (previously all under the @player-tools scope) has been split up into separate scopes to better reflect the underlying capability groupings. The table below maps each old @player-tools package to its new home:

Old PackageNew PackageRepo
@player-tools/cli@player-cli/clicli
@player-tools/dsl@player-lang/react-dsllanguage
@player-tools/fluent@player-lang/functional-dsllanguage
player_tools_dslplayer_lang_dsllanguage
@player-tools/fluent-generator@player-lang/functional-dsl-generatorlanguage
player_tools_dsl_generatorplayer_lang_dsl_generatorlanguage
@player-tools/json-language-server@player-lang/json-language-serverlanguage
@player-tools/json-language-service@player-lang/json-language-servicelanguage
@player-tools/typescript-expression-plugin@player-lang/typescript-expression-pluginlanguage
@player-tools/complexity-check-plugin@player-lang/complexity-check-pluginlanguage
@player-tools/metrics-output-plugin@player-lang/metrics-output-pluginlanguage
@player-tools/xlr@xlr-lib/xlrxlr
@player-tools/xlr-sdk@xlr-lib/xlr-sdkxlr
@player-tools/xlr-utils@xlr-lib/xlr-utilsxlr
@player-tools/xlr-converters@xlr-lib/xlr-convertersxlr

The following TypeScript-compiler-API-specific exports have been moved to the @xlr-lib/xlr-converters package and made private:

decorateNode, createDocString, createTSDocString, symbolDisplayToString, tsStripOptionalType, isExportedDeclaration, isNodeExported, getReferencedType, isTypeScriptLibType, getStringLiteralsFromUnion, buildTemplateRegex, isOptionalProperty, isGenericInterfaceDeclaration, isGenericTypeDeclaration, isTypeReferenceGeneric, TopLevelDeclaration, isTopLevelDeclaration, TopLevelNode, isTopLevelNode

This was done to make the utils package more portable and not pull in typescript as a dependency when used in web invocations of the SDK.

No from last release in old report

  • Updated to use Oclif 1.9

TBA

(Core) Partial Match Registry replaces exact matches, with log

Section titled “(Core) Partial Match Registry replaces exact matches, with log”

The Registry.set() method now replaces existing entries with exactly matching keys/matches. Whenever a replacement happens, a debug log will occur.

Example:

const registry = new Registry<string>();
// Add two entries
registry.set({ foo: "bar" }, "exact-match");
registry.set({ foo: "bar", baz: "qux" }, "more-specific-match");
// Before (old behavior):
// Calling set() again would add a third entry, potentially causing unexpected behavior
registry.set({ foo: "bar" }, "new-exact-match");
// Result: All 3 entries exist in the registry
// After (new behavior):
// Calling set() replaces the exact match only
registry.set({ foo: "bar" }, "new-exact-match");
// Result: The { foo: "bar" } entry is replaced
// The { foo: "bar", baz: "qux" } entry remains unchanged
registry.get({ foo: "bar" }); // Returns "new-exact-match"
registry.get({ foo: "bar", baz: "qux" }); // Returns "more-specific-match"

(Core) Partial Match Registry uses MOST SPECIFIC match

Section titled “(Core) Partial Match Registry uses MOST SPECIFIC match”

The Registry.get() method now consistently returns the most specific matching entry, regardless of the order entries were added. Previously, it would return the most recent matching entry.

Example:

const registry = new Registry<string>();
// Scenario: Add more specific match first, then less specific
registry.set({ foo: "bar", metaData: { role: "baz" } }, "more-specific");
registry.set({ foo: "bar" }, "less-specific");
const query = { foo: "bar", metaData: { role: "baz" }, extra: "data" };
// Before: Result would be most recent match
registry.get(query); // Returns "less-specific"
// After: Always returns the most specific match
registry.get(query); // Always returns "more-specific"

Added as<T>(_:) convenience method and AnyType (anyDictionary) subscripting for type-safe value extraction:

// Before
if case let .anyDictionary(myDict) = anyType,
let title = myDict["title"] as? String {
// Do a thing
}
// After
let title: String? = anyType["title"]?.as(String.self)

Player now includes a built-in ErrorController for structured error handling across the flow lifecycle. It is automatically instantiated when a flow starts and accessible via controllers.error in the InProgressState.

Key capabilities:

  • captureError(error) — captures any Error implementing PlayerErrorMetadata (type, optional severity, optional metadata). Returns true if recovery succeeded via errorTransitions, false if the flow was failed.
  • onError hook — plugins can tap to observe errors; return true to take ownership and skip errorState navigation.
  • errorState binding — errors are automatically written to errorState in the data model, readable in views via {{errorState.message}}, {{errorState.errorType}}, etc.
  • errorTransitions map — flows can define per-state or flow-level error routing to navigate to dedicated error views instead of failing.

Render-time error recovery — All platforms provide the ability to recover from render-time errors by calling captureError(error)

See the Error Handling docs for the full API reference.