diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index ebf0b499c..23c148685 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cfd86217..2b2a0bcc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d2e267a07 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/android/player/src/main/kotlin/com/intuit/playerui/android/compose/ComposableAsset.kt b/android/player/src/main/kotlin/com/intuit/playerui/android/compose/ComposableAsset.kt index 22358e6a9..cef7e0c32 100644 --- a/android/player/src/main/kotlin/com/intuit/playerui/android/compose/ComposableAsset.kt +++ b/android/player/src/main/kotlin/com/intuit/playerui/android/compose/ComposableAsset.kt @@ -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 @@ -102,7 +103,7 @@ public abstract class ComposableAsset( @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 } } diff --git a/android/player/src/main/kotlin/com/intuit/playerui/android/extensions/Into.kt b/android/player/src/main/kotlin/com/intuit/playerui/android/extensions/Into.kt index d12755d5a..1e3ff2004 100644 --- a/android/player/src/main/kotlin/com/intuit/playerui/android/extensions/Into.kt +++ b/android/player/src/main/kotlin/com/intuit/playerui/android/extensions/Into.kt @@ -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 @@ -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) { @@ -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) { @@ -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 @@ -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.into(root: ViewGroup) { val filtered = filterNotNull() if (filtered.isEmpty()) { diff --git a/docs/site/src/content/docs/announcements/player-one-dot-0.mdx b/docs/site/src/content/docs/announcements/player-one-dot-0.mdx index 4bb0bc3b8..7ce61fd6e 100644 --- a/docs/site/src/content/docs/announcements/player-one-dot-0.mdx +++ b/docs/site/src/content/docs/announcements/player-one-dot-0.mdx @@ -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(_:)` convenience method and `AnyType` (`anyDictionary`) subscripting for type-safe value extraction: diff --git a/plugins/auto-scroll/react/src/__tests__/plugin.test.tsx b/plugins/auto-scroll/react/src/__tests__/plugin.test.tsx index 54a67ec91..993c4144b 100644 --- a/plugins/auto-scroll/react/src/__tests__/plugin.test.tsx +++ b/plugins/auto-scroll/react/src/__tests__/plugin.test.tsx @@ -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( +
+ + + +
, + ); + + 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 => { diff --git a/plugins/auto-scroll/react/src/plugin.tsx b/plugins/auto-scroll/react/src/plugin.tsx index cfc66ec3a..aab577023 100644 --- a/plugins/auto-scroll/react/src/plugin.tsx +++ b/plugins/auto-scroll/react/src/plugin.tsx @@ -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({ diff --git a/plugins/pubsub/core/src/__tests__/plugin.test.ts b/plugins/pubsub/core/src/__tests__/plugin.test.ts index a65796fbe..adce0cee0 100644 --- a/plugins/pubsub/core/src/__tests__/plugin.test.ts +++ b/plugins/pubsub/core/src/__tests__/plugin.test.ts @@ -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", @@ -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" }); diff --git a/plugins/pubsub/core/src/index.ts b/plugins/pubsub/core/src/index.ts index 267c1ecf5..2fa890aad 100644 --- a/plugins/pubsub/core/src/index.ts +++ b/plugins/pubsub/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./plugin"; export * from "./symbols"; export * from "./handler"; +export { TinyPubSub } from "./pubsub"; diff --git a/plugins/pubsub/core/src/plugin.ts b/plugins/pubsub/core/src/plugin.ts index ee8adb8f8..e683b9c52 100644 --- a/plugins/pubsub/core/src/plugin.ts +++ b/plugins/pubsub/core/src/plugin.ts @@ -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; } /** @@ -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(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(PubSubPluginSymbol); + if (existing !== undefined) { + this.pubsub = existing.pubsub; + } } player.hooks.expressionEvaluator.tap(this.name, (expEvaluator) => { @@ -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); } @@ -90,7 +102,7 @@ export class PubSubPlugin implements PlayerPlugin { subscribe( event: T, handler: SubscribeHandler, - ) { + ): string { return this.pubsub.subscribe(event, handler); } @@ -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(); } } diff --git a/plugins/pubsub/ios/Sources/PubSubPlugin.swift b/plugins/pubsub/ios/Sources/PubSubPlugin.swift index 1a80f9ade..e6ad97103 100644 --- a/plugins/pubsub/ios/Sources/PubSubPlugin.swift +++ b/plugins/pubsub/ios/Sources/PubSubPlugin.swift @@ -37,24 +37,38 @@ public class PubSubPlugin: JSBasePlugin, NativePlugin { /// Additional options for the PubSubPlugin private var options: PubSubPluginOptions? + /// An external shared TinyPubSub instance, if provided + private var sharedPubSub: TinyPubSub? + /** Constructs a PubSubPlugin - parameters: - - eventNames: The event names to subscribe to - - eventReceived: A callback to receive events + - subscriptions: Event name / callback pairs to register + - options: Additional options such as a custom expression name + - pubsub: An optional shared `TinyPubSub` instance. When provided, this plugin + will use it as its event bus instead of creating a new one, allowing + multiple plugins (or plugins on different players) to share events. */ - public convenience init(_ subscriptions: [PubSubSubscription], options: PubSubPluginOptions? = nil) { + public convenience init( + _ subscriptions: [PubSubSubscription], + options: PubSubPluginOptions? = nil, + pubsub: TinyPubSub? = nil + ) { self.init(fileName: "PubSubPlugin.native", pluginName: "PubSubPlugin.PubSubPlugin") eventSubscriptions = subscriptions self.options = options + self.sharedPubSub = pubsub } /** - Constructs the PubSub Plugin in the given context - - parameters: - - context: The context to load the plugin into - */ + Constructs the PubSub Plugin in the given context. + If a shared `TinyPubSub` was provided it is initialized here, creating the underlying + JS TinyPubSub instance (if not already created) before the plugin constructor runs. + - parameters: + - context: The context to load the plugin into + */ override public func setup(context: JSContext) { + sharedPubSub?.setup(context: context) super.setup(context: context) for subscription in eventSubscriptions { subscribe(eventName: subscription.0, callback: subscription.1) @@ -62,8 +76,14 @@ public class PubSubPlugin: JSBasePlugin, NativePlugin { } public override func getArguments() -> [Any] { - guard let name = self.options?.expressionName else { return [] } - return [["expressionName": name]] + var config: [String: Any] = [:] + if let name = options?.expressionName { + config["expressionName"] = name + } + if let pubsubValue = sharedPubSub?.jsValue { + config["pubsub"] = pubsubValue + } + return config.isEmpty ? [] : [config] } override open func getUrlForFile(fileName: String) -> URL? { diff --git a/plugins/pubsub/ios/Sources/TinyPubSub.swift b/plugins/pubsub/ios/Sources/TinyPubSub.swift new file mode 100644 index 000000000..49e3ef17d --- /dev/null +++ b/plugins/pubsub/ios/Sources/TinyPubSub.swift @@ -0,0 +1,125 @@ +// +// TinyPubSub.swift +// PlayerUI +// + +import Foundation +import JavaScriptCore + +import PlayerUI + +/** + A handle to a JavaScript TinyPubSub instance that can be shared across multiple PubSubPlugin instances. + + Create with a `JSContext` for immediate use, or create without one and pass to a `PubSubPlugin` + which will initialize it automatically during setup. Subscribe and publish calls made before the + instance is initialized are silently ignored. + + ```swift + // Standalone usage โ€” provide a context at init: + let sharedBus = TinyPubSub(context: someContext) + + // Plugin usage โ€” the plugin initializes the shared bus for you: + let sharedBus = TinyPubSub() + let plugin1 = PubSubPlugin([], pubsub: sharedBus) + let plugin2 = PubSubPlugin([], options: PubSubPluginOptions(expressionName: "customPublish"), pubsub: sharedBus) + ``` + */ +public class TinyPubSub { + /// Reference to the underlying JS TinyPubSub instance. Set during initialization. + internal var jsValue: JSValue? + + /** + Creates a TinyPubSub instance and immediately constructs the underlying JS object + in the given context. + - parameters: + - context: The JS context in which the TinyPubSub instance will live + */ + public init(context: JSContext) { + initialize(in: context) + } + + /** + Creates an uninitialized TinyPubSub handle. Pass this to a `PubSubPlugin` and it will + initialize the underlying JS instance when the plugin is set up. + */ + public init() {} + + /** + Initializes the underlying JS TinyPubSub instance in the given context. Called automatically + by `PubSubPlugin` during setup. Subsequent calls are no-ops. + - parameters: + - context: The JS context in which to create the TinyPubSub instance + */ + internal func setup(context: JSContext) { + guard jsValue == nil else { return } + initialize(in: context) + } + + private func initialize(in context: JSContext) { + if let url = ResourceUtilities.urlForFile(name: "PubSubPlugin.native", ext: "js", bundle: Bundle.module), + let jsString = try? String(contentsOf: url, encoding: .utf8) { + context.evaluateScript(jsString) + } + jsValue = context.evaluateScript("new PubSubPlugin.TinyPubSub()") + } + + /** + Subscribe to an event on the shared bus. + - parameters: + - eventName: The name of the event to listen for + - callback: Called with the event name and any associated data when the event fires + - returns: A subscription token that can be passed to `unsubscribe` to cancel the subscription, + or `nil` if the JS instance has not been set up yet. + */ + @discardableResult + public func subscribe(eventName: String, callback: @escaping (String, AnyType?) -> Void) -> String? { + guard let ref = jsValue else { return nil } + let block: @convention(block) (JSValue?, JSValue?) -> Void = { (event, data) in + guard let name = event?.toString() else { return } + if + let isString = data?.isString, isString, + let objectString = data?.toString() + { + callback(name, .string(data: objectString)) + } else if + let object = data?.toObject(), + let objectData = try? JSONSerialization.data(withJSONObject: object), + let eventData = try? AnyTypeDecodingContext(rawData: objectData) + .inject(to: JSONDecoder()) + .decode(AnyType.self, from: objectData) + { + callback(name, eventData) + } else { + callback(name, nil) + } + } + let jsCallback = JSValue(object: block, in: ref.context) as Any + return ref.invokeMethod("subscribe", withArguments: [eventName, jsCallback])?.toString() + } + + /** + Publish an event on the shared bus. + - parameters: + - eventName: The name of the event + - eventData: Arbitrary data associated with the event + */ + public func publish(eventName: String, eventData: AnyType) { + guard + let ref = jsValue, + let data = try? JSONEncoder().encode(eventData), + let dataString = String(data: data, encoding: .utf8), + let dataObject = ref.context.evaluateScript("(\(dataString))") + else { return } + ref.invokeMethod("publish", withArguments: [eventName, dataObject]) + } + + /** + Unsubscribe using a token returned from a previous `subscribe` call. + - parameters: + - token: The token returned by `subscribe` + */ + public func unsubscribe(token: String) { + jsValue?.invokeMethod("unsubscribe", withArguments: [token]) + } +} diff --git a/plugins/pubsub/ios/Tests/TinyPubSubTests.swift b/plugins/pubsub/ios/Tests/TinyPubSubTests.swift new file mode 100644 index 000000000..48e9a3c4c --- /dev/null +++ b/plugins/pubsub/ios/Tests/TinyPubSubTests.swift @@ -0,0 +1,122 @@ +// +// TinyPubSubTests.swift +// PlayerUI +// + +import Foundation +import XCTest +import JavaScriptCore +@testable import PlayerUI +@testable import PlayerUITestUtilitiesCore +@testable import PlayerUIPubSubPlugin + +class TinyPubSubTests: XCTestCase { + // MARK: - Helpers + + private func makeContext() -> JSContext { + let context = JSContext()! + JSUtilities.polyfill(context) + return context + } + + // MARK: - Initialization + + func testInitWithContextCreatesJSInstance() { + let bus = TinyPubSub(context: makeContext()) + XCTAssertNotNil(bus.jsValue, "jsValue should be set after init(context:)") + } + + func testInitWithoutContextLeavesJSInstanceNil() { + let bus = TinyPubSub() + XCTAssertNil(bus.jsValue, "jsValue should be nil before setup(context:) is called") + } + + func testSetupIsIdempotent() { + let bus = TinyPubSub() + let context1 = makeContext() + let context2 = makeContext() + + bus.setup(context: context1) + let first = bus.jsValue + + bus.setup(context: context2) + XCTAssertTrue(bus.jsValue === first, "jsValue should not change after the first setup") + } + + // MARK: - Subscribe / Publish + + func testSubscribeAndPublishString() { + let bus = TinyPubSub(context: makeContext()) + + let expectation = XCTestExpectation(description: "string event received") + bus.subscribe(eventName: "test") { _, data in + guard case .string(let result) = data else { return XCTFail("expected string") } + XCTAssertEqual(result, "hello") + expectation.fulfill() + } + + bus.publish(eventName: "test", eventData: .string(data: "hello")) + wait(for: [expectation], timeout: 2) + } + + func testSubscribeBeforeContextIsIgnored() { + let bus = TinyPubSub() + // No context yet โ€” subscribe should not crash and should return nil + let token = bus.subscribe(eventName: "test") { _, _ in XCTFail("should not be called") } + XCTAssertNil(token) + } + + // MARK: - Unsubscribe + + func testUnsubscribeStopsEvents() { + let bus = TinyPubSub(context: makeContext()) + + var callCount = 0 + let token = bus.subscribe(eventName: "test") { _, _ in callCount += 1 } + + bus.publish(eventName: "test", eventData: .string(data: "first")) + XCTAssertEqual(callCount, 1) + + bus.unsubscribe(token: token!) + bus.publish(eventName: "test", eventData: .string(data: "second")) + XCTAssertEqual(callCount, 1, "handler should not fire after unsubscribe") + } + + // MARK: - Shared bus via plugins + + func testSharedBusViaPluginsSharesEvents() { + let context = makeContext() + let sharedBus = TinyPubSub() + + let plugin1 = PubSubPlugin([], pubsub: sharedBus) + let plugin2 = PubSubPlugin( + [], + options: PubSubPluginOptions(expressionName: "customPublish"), + pubsub: sharedBus + ) + + plugin1.context = context + plugin2.context = context + + let expectation = XCTestExpectation(description: "event received via shared bus") + sharedBus.subscribe(eventName: "ping") { _, data in + guard case .string(let result) = data else { return XCTFail("expected string") } + XCTAssertEqual(result, "pong") + expectation.fulfill() + } + + // Publish through plugin1 โ€” subscriber on sharedBus should fire + plugin1.publish(eventName: "ping", eventData: .string(data: "pong")) + wait(for: [expectation], timeout: 2) + } + + func testPluginSetupInitializesSharedBus() { + let sharedBus = TinyPubSub() + XCTAssertNil(sharedBus.jsValue) + + let plugin = PubSubPlugin([], pubsub: sharedBus) + plugin.context = makeContext() + + XCTAssertNotNil(sharedBus.jsValue, "plugin setup should initialize the shared bus") + } +} diff --git a/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/PubSubPlugin.kt b/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/PubSubPlugin.kt index d2eb3fe3b..b2dcb6e39 100644 --- a/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/PubSubPlugin.kt +++ b/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/PubSubPlugin.kt @@ -12,13 +12,26 @@ import kotlinx.serialization.Serializable /** Core plugin wrapper providing pub-sub support to the JVM player */ public class PubSubPlugin( public val config: Config? = null, + public val sharedPubSub: TinyPubSub? = null, ) : JSScriptPluginWrapper(PLUGIN_NAME, sourcePath = BUNDLED_SOURCE_PATH) { override fun apply(runtime: Runtime<*>) { - config?.let { - runtime.load(ScriptContext(script, BUNDLED_SOURCE_PATH)) - runtime.add("pubsubConfig", config) - instance = runtime.buildInstance("(new $name(pubsubConfig))") - } ?: super.apply(runtime) + runtime.load(ScriptContext(script, BUNDLED_SOURCE_PATH)) + + val pubSubVarName = sharedPubSub?.getOrCreate(runtime) + + instance = when { + pubSubVarName != null && config != null -> { + runtime.add("pubsubConfig", config) + runtime.buildInstance("(new $name({...pubsubConfig, pubsub: $pubSubVarName}))") + } + pubSubVarName != null -> + runtime.buildInstance("(new $name({pubsub: $pubSubVarName}))") + config != null -> { + runtime.add("pubsubConfig", config) + runtime.buildInstance("(new $name(pubsubConfig))") + } + else -> runtime.buildInstance() + } } /** diff --git a/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/TinyPubSub.kt b/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/TinyPubSub.kt new file mode 100644 index 000000000..a18057cd8 --- /dev/null +++ b/plugins/pubsub/jvm/src/main/kotlin/com/intuit/playerui/plugins/pubsub/TinyPubSub.kt @@ -0,0 +1,67 @@ +package com.intuit.playerui.plugins.pubsub + +import com.intuit.playerui.core.bridge.Node +import com.intuit.playerui.core.bridge.getInvokable +import com.intuit.playerui.core.bridge.runtime.Runtime + +/** + * A handle to a JavaScript TinyPubSub instance that can be shared across multiple [PubSubPlugin] + * instances. Create one and pass it to multiple plugin constructors so they all share the same + * event bus. + * + * The underlying JS instance is created the first time a [PubSubPlugin] that holds this handle + * is applied to a runtime. [subscribe], [publish], and [unsubscribe] calls made before that + * point will throw. + * + * ```kotlin + * val sharedBus = TinyPubSub() + * val plugin1 = PubSubPlugin(sharedPubSub = sharedBus) + * val plugin2 = PubSubPlugin(Config("customPublish"), sharedPubSub = sharedBus) + * + * // After the player is set up, use the bus directly: + * sharedBus.subscribe("myEvent") { event, data -> /* ... */ } + * ``` + */ +public class TinyPubSub { + internal var node: Node? = null + private var jsVarName: String? = null + + /** + * Called by [PubSubPlugin] after the bundle has been loaded into [runtime]. + * Creates the JS TinyPubSub instance on first call; subsequent calls are no-ops and + * return the same variable name. + */ + internal fun getOrCreate(runtime: Runtime<*>): String = jsVarName ?: run { + val varName = "_playerui_pubsub_${System.identityHashCode(this)}" + runtime.execute("globalThis.$varName = new PubSubPlugin.TinyPubSub()") + node = runtime.execute("globalThis.$varName") as? Node + jsVarName = varName + varName + } + + /** + * Subscribe to an event + * @param eventName The name of the event to attach a handler to + * @param block The handler to call when the event is received + * @return subscription token used to [unsubscribe] + */ + public fun subscribe(eventName: String, block: (String, Any?) -> Unit): String = + node?.getInvokable("subscribe")!!(eventName, block) + + /** + * Cancel subscription registered with [token] + * @param token subscription token obtained from [subscribe] call + */ + public fun unsubscribe(token: String) { + node?.getInvokable("unsubscribe")!!(token) + } + + /** + * Publish an event through this pubsub instance + * @param eventName The name of the event + * @param eventData Arbitrary data associated with the event + */ + public fun publish(eventName: String, eventData: Any) { + node?.getInvokable("publish")!!(eventName, eventData) + } +} diff --git a/plugins/pubsub/jvm/src/test/kotlin/com/intuit/playerui/plugins/pubsub/TinyPubSubTest.kt b/plugins/pubsub/jvm/src/test/kotlin/com/intuit/playerui/plugins/pubsub/TinyPubSubTest.kt new file mode 100644 index 000000000..94ae47fbb --- /dev/null +++ b/plugins/pubsub/jvm/src/test/kotlin/com/intuit/playerui/plugins/pubsub/TinyPubSubTest.kt @@ -0,0 +1,115 @@ +package com.intuit.playerui.plugins.pubsub + +import com.intuit.playerui.utils.test.PlayerTest +import com.intuit.playerui.utils.test.setupPlayer +import org.amshove.kluent.`should be equal to` +import org.amshove.kluent.`should be instance of` +import org.amshove.kluent.`should be null` +import org.amshove.kluent.shouldNotBe +import org.junit.jupiter.api.TestTemplate + +internal class TinyPubSubTest : PlayerTest() { + override val plugins = listOf() + + // MARK: - Initialization + + @TestTemplate + fun `node is null before plugin setup`() { + val bus = TinyPubSub() + bus.node.`should be null`() + } + + @TestTemplate + fun `node is set after plugin applies to runtime`() { + val bus = TinyPubSub() + setupPlayer(PubSubPlugin(sharedPubSub = bus)) + bus.node shouldNotBe null + } + + // MARK: - Subscribe / Publish + + @TestTemplate + fun `subscribe returns a String token`() { + val bus = TinyPubSub() + setupPlayer(PubSubPlugin(sharedPubSub = bus)) + bus.subscribe("event") { _, _ -> } `should be instance of` String::class + } + + @TestTemplate + fun `subscribe and publish delivers event`() { + val bus = TinyPubSub() + setupPlayer(PubSubPlugin(sharedPubSub = bus)) + + var receivedName: String? = null + var receivedData: Any? = null + bus.subscribe("myEvent") { name, data -> + receivedName = name + receivedData = data + } + + bus.publish("myEvent", "payload") + + receivedName `should be equal to` "myEvent" + receivedData `should be equal to` "payload" + } + + // MARK: - Unsubscribe + + @TestTemplate + fun `unsubscribe stops event delivery`() { + val bus = TinyPubSub() + setupPlayer(PubSubPlugin(sharedPubSub = bus)) + + var callCount = 0 + val token = bus.subscribe("evt") { _, _ -> callCount++ } + + bus.publish("evt", "first") + callCount `should be equal to` 1 + + bus.unsubscribe(token) + bus.publish("evt", "second") + callCount `should be equal to` 1 + } + + // MARK: - Shared bus across plugins + + @TestTemplate + fun `shared bus routes events from both expression names`() { + val sharedBus = TinyPubSub() + setupPlayer( + PubSubPlugin(sharedPubSub = sharedBus), + PubSubPlugin(PubSubPlugin.Config("customPublish"), sharedPubSub = sharedBus), + ) + + var callCount = 0 + sharedBus.subscribe("ping") { _, _ -> callCount++ } + + sharedBus.publish("ping", "data") + callCount `should be equal to` 1 + } + + @TestTemplate + fun `plugin setup initializes shared bus node`() { + val sharedBus = TinyPubSub() + sharedBus.node.`should be null`() + + setupPlayer(PubSubPlugin(sharedPubSub = sharedBus)) + sharedBus.node shouldNotBe null + } + + @TestTemplate + fun `second plugin using same bus does not reinitialize it`() { + val sharedBus = TinyPubSub() + setupPlayer( + PubSubPlugin(sharedPubSub = sharedBus), + PubSubPlugin(PubSubPlugin.Config("altPublish"), sharedPubSub = sharedBus), + ) + + // Both plugins share the same node reference + val receivedEvents = mutableListOf() + sharedBus.subscribe("test") { _, _ -> receivedEvents.add("hit") } + + sharedBus.publish("test", "x") + receivedEvents.size `should be equal to` 1 + } +}