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
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:

jobs:
claude-review:
if: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
if: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
# 0.15.2-next.2 (Thu Mar 19 2026)

#### 🐛 Bug Fix

- Cancel stale-map polyfill animation on page transition [#826](https://github.com/player-ui/player/pull/826) ([@cehan-Chloe](https://github.com/cehan-Chloe))

#### 📝 Documentation

- Add CLAUDE.md with guidelines for reporting and reviewing pull requests [#827](https://github.com/player-ui/player/pull/827) ([@spentacular](https://github.com/spentacular))

#### Authors: 2

- Chloeeeeeee ([@cehan-Chloe](https://github.com/cehan-Chloe))
- Spencer Hamm ([@spentacular](https://github.com/spentacular))

---

# 0.15.2-next.1 (Mon Mar 16 2026)

#### 🐛 Bug Fix

- Fix: Compose android view must launch on main thread [#820](https://github.com/player-ui/player/pull/820) ([@A1shK](https://github.com/A1shK) [@kharrop](https://github.com/kharrop))
- Removing docs-preview from forks [#824](https://github.com/player-ui/player/pull/824) ([@kharrop](https://github.com/kharrop))
- Add Claude Code GitHub Workflow [#822](https://github.com/player-ui/player/pull/822) ([@spentacular](https://github.com/spentacular))

#### Authors: 3

- [@A1shK](https://github.com/A1shK)
- Kelly Harrop ([@kharrop](https://github.com/kharrop))
- Spencer Hamm ([@spentacular](https://github.com/spentacular))

---

# 0.15.1 (Mon Mar 16 2026)

#### 🐛 Bug Fix
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
When reporting information to me, be extremely concise and sacrifice grammar for the sake of concision.

When reviewing a pull request provide a short paragraph with a summary of the changes first. Flag only significant bugs, ignore nitpicks. Use mermaid diagrams, tables, etc. where it will help illustrate a point.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer

Expand Down Expand Up @@ -102,7 +103,7 @@ public abstract class ComposableAsset<Data>(
@Composable
private fun RenderableAsset.composeAndroidView(modifier: Modifier = Modifier, styles: Styles? = null) {
AndroidView(factory = ::FrameLayout, modifier) {
hydrationScope.launch {
hydrationScope.launch(Dispatchers.Main) {
render(styles) into it
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.intuit.playerui.android.extensions
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.MainThread
import androidx.core.view.children
import androidx.transition.Transition
import androidx.transition.TransitionManager
Expand All @@ -15,6 +16,7 @@ import androidx.transition.TransitionManager
* @receiver [View] to inject
* @param root [FrameLayout] to be injected to
*/
@MainThread
public infix fun View?.into(root: FrameLayout) {
val existing = root.getChildAt(0)
if (this != existing) {
Expand All @@ -33,6 +35,7 @@ public infix fun View?.into(root: FrameLayout) {
* @param transition [Transition] to take effect if given
*/

@MainThread
public fun View?.transitionInto(root: FrameLayout, transition: Transition?) {
root.removeAllViews()
if (this == null) {
Expand All @@ -58,6 +61,7 @@ public fun View?.transitionInto(root: FrameLayout, transition: Transition?) {
* @receiver [View] to inject
* @param root [ViewGroup] to be injected to
*/
@MainThread
public infix fun View?.into(root: ViewGroup) {
if (this == null) {
root.visibility = View.GONE
Expand All @@ -78,6 +82,7 @@ public infix fun View?.into(root: ViewGroup) {
* @receiver Collection of [View]s to inject
* @param root [ViewGroup] to be injected to
*/
@MainThread
public infix fun List<View?>.into(root: ViewGroup) {
val filtered = filterNotNull()
if (filtered.isEmpty()) {
Expand Down
46 changes: 46 additions & 0 deletions docs/site/src/content/docs/announcements/player-one-dot-0.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,52 @@ targets: [

## New Features

### `PubSubPlugin`: injectable pubsub instance and per-instance isolation

The `PubSubPlugin` now creates a **fresh `TinyPubSub` instance for every plugin construction** instead of sharing a module-level singleton. This means two `PubSubPlugin` instances that are not registered to the same player will no longer accidentally share an event bus.

In addition, you can now pass an external `TinyPubSub` instance (TypeScript / Kotlin) or a `TinyPubSub` handle (Swift) to the constructor. This lets you wire multiple plugins—or even multiple players—to the same bus, and pairs naturally with a second plugin that uses a custom `expressionName`.

`TinyPubSub` is now exported from `@player-ui/pubsub-plugin` for TypeScript consumers.

#### TypeScript

```ts
import { TinyPubSub, PubSubPlugin } from "@player-ui/pubsub-plugin";

const sharedBus = new TinyPubSub();

const plugin1 = new PubSubPlugin({ pubsub: sharedBus });
const plugin2 = new PubSubPlugin({ expressionName: "customPublish", pubsub: sharedBus });

// Both plugins route events through sharedBus
sharedBus.subscribe("myEvent", (event, data) => { /* ... */ });
```

#### Swift

```swift
let sharedBus = TinyPubSub()

let plugin1 = PubSubPlugin([], pubsub: sharedBus)
let plugin2 = PubSubPlugin([], options: PubSubPluginOptions(expressionName: "customPublish"), pubsub: sharedBus)

// After the player is started and the plugins are set up, subscribe/publish directly:
let token = sharedBus.subscribe(eventName: "myEvent") { event, data in /* ... */ }
sharedBus.unsubscribe(token: token ?? "")
```

#### Kotlin

```kotlin
val sharedBus = TinyPubSub()

val plugin1 = PubSubPlugin(sharedPubSub = sharedBus)
val plugin2 = PubSubPlugin(Config("customPublish"), sharedPubSub = sharedBus)

sharedBus.subscribe("myEvent") { event, data -> /* ... */ }
```

### Convenience Methods for `AnyType`

Added `as<T>(_:)` convenience method and `AnyType` (`anyDictionary`) subscripting for type-safe value extraction:
Expand Down
57 changes: 57 additions & 0 deletions plugins/auto-scroll/react/src/__tests__/plugin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,63 @@ describe("scroll reset on view transition", () => {
});
});

describe("scroll reset on view transition cancels in-flight polyfill animation", () => {
test("dispatches a wheel event and calls scroll(0,0) on transition", async () => {
vitest.clearAllMocks();

const dispatchSpy = vitest
.spyOn(window, "dispatchEvent")
.mockImplementation(() => true);
const scrollSpy = vitest
.spyOn(window, "scroll")
.mockImplementation(() => {});

vitest.spyOn(document, "getElementById").mockReturnValue(null);

const wp = new ReactPlayer({
plugins: [
new AssetTransformPlugin([
[{ type: "action" }, actionTransform],
[{ type: "input" }, inputTransform],
[{ type: "info" }, infoTransform],
]),
new CommonTypesPlugin(),
new AutoScrollManagerPlugin({}),
],
});
wp.assetRegistry.set({ type: "info" }, Info);
wp.assetRegistry.set({ type: "action" }, Action);
wp.assetRegistry.set({ type: "input" }, Input);

wp.start(twoViewFlow as any);

const { container } = render(
<div>
<React.Suspense fallback="loading...">
<wp.Component />
</React.Suspense>
</div>,
);

await act(() => waitFor(() => {}));

// navigate to page 2 — triggers the transition hook
const action = await findByRole(container, "button");
act(() => action.click());
await act(() => waitFor(() => {}));

// wheel event must have been dispatched to cancel the polyfill's RAF
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "wheel" }),
);

// scroll must be reset to top
expect(scrollSpy).toHaveBeenCalledWith(0, 0);

vitest.restoreAllMocks();
});
});

describe("getFirstScrollableElement unit tests", () => {
const defineGetElementId = (bcrValues: any[]) => {
return (idIn: string): HTMLElement | null => {
Expand Down
3 changes: 2 additions & 1 deletion plugins/auto-scroll/react/src/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ export class AutoScrollManagerPlugin implements ReactPlayerPlugin {
this.failedNavigation = false;
this.alreadyScrolledTo = [];
this.clearScrollableMap();
// Reset scroll position for new view
// Cancel any in-flight polyfill smooth-scroll animation before resetting.
window.dispatchEvent(new WheelEvent("wheel", { bubbles: true }));
window.scroll(0, 0);
});
flow.hooks.skipTransition.intercept({
Expand Down
42 changes: 42 additions & 0 deletions plugins/pubsub/core/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { test, expect, vitest } from "vitest";
import { Player } from "@player-ui/player";
import { PubSubPlugin } from "../plugin";
import { PubSubPluginSymbol } from "../symbols";
import { TinyPubSub } from "../pubsub";

const minimal = {
id: "minimal",
Expand Down Expand Up @@ -146,6 +147,47 @@ test("only calls subscription once if multiple pubsub plugins are registered", (
expect(topLevel).toBeCalledWith("pet.names", ["ginger", "daisy"]);
});

test("uses an external shared pubsub instance across two plugins", () => {
const sharedPubSub = new TinyPubSub();
const pubsub1 = new PubSubPlugin({ pubsub: sharedPubSub });
const pubsub2 = new PubSubPlugin({
expressionName: "customPublish",
pubsub: sharedPubSub,
});

const player = new Player({ plugins: [pubsub1, pubsub2] });

const spy = vitest.fn();
pubsub1.subscribe("pet", spy);

player.start(multistart as any);

// both publish() and customPublish() expressions hit the same shared bus
expect(spy).toBeCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(1, "pet", ["ginger", "daisy"]);
expect(spy).toHaveBeenNthCalledWith(2, "pet", ["ginger", "daisy"]);
});

test("external pubsub is not replaced by another plugin's pubsub on the same player", () => {
const sharedPubSub = new TinyPubSub();
const pubsub1 = new PubSubPlugin();
const pubsub2 = new PubSubPlugin({
expressionName: "customPublish",
pubsub: sharedPubSub,
});

const player = new Player({ plugins: [pubsub1, pubsub2] });

const spy = vitest.fn();
// subscribe via the external pubsub directly
sharedPubSub.subscribe("pet", spy);

// customName flow fires customPublish("pet.names", ...), routed through pubsub2's external bus
player.start(customName as any);

expect(spy).toBeCalledTimes(1);
});

test("calls subscription for each pubsub registered through pubsubplugin", () => {
const pubsub = new PubSubPlugin();
const pubsub2 = new PubSubPlugin({ expressionName: "customPublish" });
Expand Down
1 change: 1 addition & 0 deletions plugins/pubsub/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./plugin";
export * from "./symbols";
export * from "./handler";
export { TinyPubSub } from "./pubsub";
44 changes: 28 additions & 16 deletions plugins/pubsub/core/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import type {
PlayerPlugin,
ExpressionContext,
} from "@player-ui/player";
import type { SubscribeHandler, TinyPubSub } from "./pubsub";
import { pubsub } from "./pubsub";
import type { SubscribeHandler } from "./pubsub";
import { pubsub, TinyPubSub } from "./pubsub";
import { PubSubPluginSymbol } from "./symbols";

export interface PubSubConfig {
/** A custom expression name to register */
expressionName: string;
expressionName?: string;
/**
* An external TinyPubSub instance to use instead of creating a new one.
* When provided, this instance is always used and will not be replaced by
* another registered PubSubPlugin's instance on the same player.
* This allows sharing a single pubsub bus across multiple plugins or players.
*/
pubsub?: TinyPubSub;
}

/**
Expand All @@ -24,24 +31,29 @@ export interface PubSubConfig {
export class PubSubPlugin implements PlayerPlugin {
name = "pub-sub";

static Symbol = PubSubPluginSymbol;
public readonly symbol = PubSubPlugin.Symbol;
static Symbol: symbol = PubSubPluginSymbol;
public readonly symbol: symbol = PubSubPlugin.Symbol;

protected pubsub: TinyPubSub;

private expressionName: string;

private usesExternalPubSub: boolean;

constructor(config?: PubSubConfig) {
this.expressionName = config?.expressionName ?? "publish";
this.pubsub = pubsub;
this.pubsub = config?.pubsub ?? pubsub;
this.usesExternalPubSub = config?.pubsub !== undefined;
}

apply(player: Player) {
// if there is already a pubsub plugin, reuse its pubsub instance
// to maintain the singleton across bundles for iOS/Android
const existing = player.findPlugin<PubSubPlugin>(PubSubPluginSymbol);
if (existing !== undefined) {
this.pubsub = existing.pubsub;
apply(player: Player): void {
// if there is already a pubsub plugin and no external pubsub was provided,
// reuse its pubsub instance to maintain the singleton across bundles for iOS/Android
if (!this.usesExternalPubSub) {
const existing = player.findPlugin<PubSubPlugin>(PubSubPluginSymbol);
if (existing !== undefined) {
this.pubsub = existing.pubsub;
}
}

player.hooks.expressionEvaluator.tap(this.name, (expEvaluator) => {
Expand Down Expand Up @@ -76,7 +88,7 @@ export class PubSubPlugin implements PlayerPlugin {
* @param event - The name of the event to publish. Can take sub-topics like: foo.bar
* @param data - Any additional data to attach to the event
*/
publish(event: string, ...args: unknown[]) {
publish(event: string, ...args: unknown[]): void {
this.pubsub.publish(event, ...args);
}

Expand All @@ -90,7 +102,7 @@ export class PubSubPlugin implements PlayerPlugin {
subscribe<T extends string, A extends unknown[]>(
event: T,
handler: SubscribeHandler<T, A>,
) {
): string {
return this.pubsub.subscribe(event, handler);
}

Expand All @@ -99,14 +111,14 @@ export class PubSubPlugin implements PlayerPlugin {
*
* @param token - A token from a `subscribe` call
*/
unsubscribe(token: string) {
unsubscribe(token: string): void {
this.pubsub.unsubscribe(token);
}

/**
* Remove all subscriptions
*/
clear() {
clear(): void {
this.pubsub.clear();
}
}
Loading
Loading