Skip to content

ExternalStatePlugin allows overrides#817

Merged
KVSRoyal merged 13 commits intoplayer-1-dot-zerofrom
external-action
Apr 14, 2026
Merged

ExternalStatePlugin allows overrides#817
KVSRoyal merged 13 commits intoplayer-1-dot-zerofrom
external-action

Conversation

@KVSRoyal
Copy link
Copy Markdown
Member

@KVSRoyal KVSRoyal commented Mar 13, 2026

This is now the jumbo PR covering all 3 platforms. (Was previously just for core.)

What

Please review the release notes that are part of the PR. (Putting them there helps me keep redundancy down and keep the docs accurate.)

Why

Deterministic Overrides: Users want predictable behaviour when multiple plugins or handlers are registered. The registry ensures that:

  • More specific matchers always take precedence (e.g., { ref: "x", type: "y" } beats { ref: "x" })
  • Later registrations replace earlier ones with the same specificity
  • Behaviour is consistent regardless of registration order

Existing Patterns: We chose to reuse Registry as this follows established patterns in Player for Asset registration. Hopefully this will make it easier for users to understand the logic.

Visibility: We chose to add debug logging when handlers are overridden to make it clear to developers what's happening during plugin composition.

Change Type (required)

Indicate the type of change your pull request is:

  • patch
  • minor
  • major
  • N/A (part of 1.0)

Does your PR have any documentation updates?

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

Release Notes

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. See docs site for full examples.

Handler matching and specificity

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 across plugins

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.

Missing handlers

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.

Note

In a future release, unhandled external states will automatically report an error via the Error Controller.

@KetanReddy
Copy link
Copy Markdown
Member

KetanReddy commented Mar 13, 2026

Fix bug with sorted-array library type def (incorrect compare function and type definitions)

Maybe out of scope for this PR but sorted-array is deprecated so we might want to replace it?

Comment thread core/partial-match-registry/src/index.ts
Comment thread core/partial-match-registry/src/index.ts Outdated
Comment thread core/partial-match-registry/src/index.ts Outdated
Comment thread core/partial-match-registry/src/index.ts
Comment thread docs/site/src/content/docs/announcements/player-one-dot-0.mdx
Comment thread plugins/external-action/core/src/index.ts Outdated
Comment thread plugins/external-state/core/src/index.ts
@KVSRoyal
Copy link
Copy Markdown
Member Author

Reopening to see if I can trigger Claude.

@KVSRoyal KVSRoyal reopened this Mar 17, 2026
@KVSRoyal
Copy link
Copy Markdown
Member Author

KVSRoyal commented Mar 17, 2026

@claude You are a principal engineer specialized in https://player-ui.github.io/next/ who follows best practices. Review this PR. Please follow these additional guidelines:

  1. Use simple tenses where possible. Simple verb tenses (past, present, and future) keep things concise and people process simpler tenses faster than complex ones.
  2. Keep words shorter than 9 characters, where possible. Technical terms are okay. Studies show that people are more likely to skip longer words.
  3. Keep sentences shorter than 20 words. Longer sentences are harder to follow.
  4. Use mermaid diagrams, tables, etc. where it will help illustrate a point.

@claude
Copy link
Copy Markdown

claude Bot commented Mar 17, 2026

Claude finished @KVSRoyal's task in 2m 2s —— View job


PR Review: ExternalAction Handler Allows Overrides

  • Read changed files
  • Review core registry changes
  • Review ExternalAction plugin changes
  • Review tests
  • Review docs changes
  • Post final review

Summary

Good work overall. The registry-based approach is clean and follows existing Player patterns. Found a few bugs and design concerns below.


Bugs

1. Logger set too late in Registry constructor

core/partial-match-registry/src/index.ts:48

constructor(initialSet?: Array<[any, V]>, logger?: Logger | undefined) {
  initialSet?.forEach(([match, value]) => {
    this.set(match, value); // ❌ logger is undefined here
  });
  this.logger = logger;     // too late
}

this.logger must be set before calling this.set. If initialSet has two entries with the same key, the debug log never fires.

Fix this →


2. Mixed test runner imports

plugins/external-action/core/src/__tests__/index.test.ts:5

import { test, expect, vitest } from "vitest";
import { describe } from "node:test"; // ❌ wrong runner

describe comes from node:test but all other helpers come from vitest. This will likely cause issues. Change to:

import { test, expect, vitest, describe } from "vitest";

Fix this →


Design Concerns

3. Symbol namespace may collide

plugins/external-action/core/src/symbols.ts:1

export const ExternalActionPluginSymbol = Symbol.for("ExternalActionPlugin");

Symbol.for is global. Any other package using "ExternalActionPlugin" will share this symbol. Use a namespaced key:

Symbol.for("@player-ui/ExternalActionPlugin")

Fix this →

4. registry is private but accessed cross-instance

plugins/external-action/core/src/index.ts:152

this.registry = existing.registry; // accessing private field on another instance

TypeScript allows private access within the same class—across instances. But this is a code smell. The reviewer also flagged this. Making registry public (or readonly) lets you drop the isFirstPluginInstance flag and simplify createRegistry. Here's the flow:

flowchart TD
  A[apply called] --> B{findPlugin returns existing?}
  B -- No --> C[Create new Registry]
  B -- Yes --> D[Use existing.registry]
  C --> E[Tap flow hooks]
  D --> F[Skip tap]
Loading

With a public registry, you can store it once and drop the isFirstPluginInstance field entirely.

5. Optional chaining on registry is misleading

plugins/external-action/core/src/index.ts:173

this.registry?.set(state, handler); // registry is always set when this runs

registerHandlers is called after createRegistry. registry is always set at this point. Remove the ?. or make the type non-optional.


Specificity Logic

This is the core behavior change. Good to call it out explicitly:

Scenario Behavior
Two entries, same key Later one wins (replaces)
Two entries, different specificity More specific always wins
Same specificity, different values Latest registration wins

The set logic uses both matchers to check mutual equality. This is correct:

const existingIndex = this.store.findIndex(
  (entry) => entry.matcher(match) && matcher(entry.key),
);

This handles asymmetry in partial matching. Good approach.


Minor Nits

  • entry && entry.matcher(query) in get — the entry && check is unnecessary. store only holds RegistryIndex<V> objects. Remove for clarity.
  • Missing test: no handler registered for an external state. What happens? The handler is undefined, so handler?.(...) returns undefined. No transition. This should be a test case.
  • The Registry constructor logger param has redundant | undefined — TypeScript adds that for optional params.

What's Good

  • The registry-based API is clean and follows Player's AssetRegistry pattern.
  • Tests cover: specificity ordering, override behavior, async, error, and edge cases.
  • Docs are updated with examples and cross-links.
  • Debug logging on override is a good DX addition.
  • The shouldTransition helper is clear and reusable.

Comment thread core/partial-match-registry/src/index.ts Outdated
Comment thread docs/site/src/content/docs/plugins/multiplatform/external-action.mdx Outdated
Comment thread docs/site/src/content/docs/plugins/multiplatform/external-action.mdx Outdated
Comment thread plugins/external-state/core/src/index.ts Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 2, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.00%. Comparing base (a0a817c) to head (079dd91).
⚠️ Report is 32 commits behind head on player-1-dot-zero.

Additional details and impacted files
@@           Coverage Diff            @@
##   player-1-dot-zero   #817   +/-   ##
========================================
========================================

☔ 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 Apr 2, 2026

Bundle Report

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

Detailed changes
Bundle name Size Change
plugins/common-expressions/core 407.65kB 1.2kB (0.29%) ⬆️
plugins/check-path/core 421.8kB 1.2kB (0.28%) ⬆️
plugins/stage-revert-data/core 385.42kB 1.2kB (0.31%) ⬆️
plugins/auto-scroll/react 19.9kB 228 bytes (1.16%) ⬆️
plugins/external-action/core* 18.89kB -938 bytes (-4.73%) ⬇️
plugins/async-node/core 459.46kB 1.2kB (0.26%) ⬆️
plugins/markdown/core 662.46kB 1.2kB (0.18%) ⬆️
core/partial-match-registry 32.68kB 4.6kB (16.37%) ⬆️
plugins/reference-assets/core 464.42kB 1.2kB (0.26%) ⬆️
plugins/beacon/core 403.4kB 1.2kB (0.3%) ⬆️
plugins/metrics/core 439.96kB 1.2kB (0.27%) ⬆️
plugins/common-types/core 481.98kB 1.2kB (0.25%) ⬆️
core/player 953.28kB 1.2kB (0.13%) ⬆️
plugins/external-state/core 47.65kB 47.65kB (100%) ⬆️

ℹ️ *Bundle size includes cached data from a previous commit

Affected Assets, Files, and Routes:

view changes for bundle: plugins/async-node/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
AsyncNodePlugin.native.js 1.2kB 411.61kB 0.29%
view changes for bundle: plugins/common-expressions/core

Assets Changed:

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

Assets Changed:

Asset Name Size Change Total Size Change (%)
ReferenceAssetsPlugin.native.js 1.2kB 434.52kB 0.28%
view changes for bundle: plugins/check-path/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
CheckPathPlugin.native.js 1.2kB 392.32kB 0.31%
view changes for bundle: core/partial-match-registry

Assets Changed:

Asset Name Size Change Total Size Change (%)
Registry.native.js 1.2kB 19.99kB 6.36% ⚠️
cjs/index.cjs 1.13kB 5.38kB 26.72% ⚠️
index.legacy-esm.js 1.13kB 3.66kB 44.91% ⚠️
index.mjs 1.13kB 3.66kB 44.91% ⚠️
view changes for bundle: plugins/common-types/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
CommonTypesPlugin.native.js 1.2kB 410.81kB 0.29%
view changes for bundle: plugins/stage-revert-data/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
StageRevertDataPlugin.native.js 1.2kB 378.6kB 0.32%
view changes for bundle: plugins/markdown/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
MarkdownPlugin.native.js 1.2kB 637.3kB 0.19%
view changes for bundle: plugins/beacon/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
BeaconPlugin.native.js 1.2kB 388.93kB 0.31%
view changes for bundle: plugins/external-action/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
ExternalActionPlugin.native.js -641 bytes 12.49kB -4.88%
cjs/index.cjs -99 bytes 2.82kB -3.4%
index.legacy-esm.js -99 bytes 1.79kB -5.23%
index.mjs -99 bytes 1.79kB -5.23%
view changes for bundle: plugins/metrics/core

Assets Changed:

Asset Name Size Change Total Size Change (%)
MetricsPlugin.native.js 1.2kB 407.62kB 0.29%
view changes for bundle: core/player

Assets Changed:

Asset Name Size Change Total Size Change (%)
Player.native.js 1.2kB 403.2kB 0.3%
view changes for bundle: plugins/auto-scroll/react

Assets Changed:

Asset Name Size Change Total Size Change (%)
cjs/index.cjs 76 bytes 8.06kB 0.95%
index.legacy-esm.js 76 bytes 5.92kB 1.3%
index.mjs 76 bytes 5.92kB 1.3%

@KVSRoyal KVSRoyal changed the title ExternalAction Handler allows overrides ExternalStatePlugin allows overrides Apr 6, 2026
@KVSRoyal
Copy link
Copy Markdown
Member Author

KVSRoyal commented Apr 6, 2026

@claude Review this PR

@claude
Copy link
Copy Markdown

claude Bot commented Apr 6, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@KVSRoyal
Copy link
Copy Markdown
Member Author

KVSRoyal commented Apr 6, 2026

/docs

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

Copy link
Copy Markdown
Member

@sugarmanz sugarmanz left a comment

Choose a reason for hiding this comment

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

Small comments regarding documentation, otherwise looks good!

]
```

### `ExternalActionPlugin` renamed to `ExternalStatePlugin` with new handler API
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.

Can these be added to the PR # Release Notes section so they get picked up and added to the CHANGELOG.md?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, sure, adding. Although for the 1.0 stuff I think we've so far just been putting things in the migration docs file.

Comment on lines +353 to +358
ExternalStateHandler(
ref = "my-action",
handlerFunction = ExternalStateHandler.Function { state, options, transition ->
transition("Next")
}
)
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.

Does the trailing lambda syntax work here?

  ExternalStateHandler(ref = "my-action") { state, options, transition ->
    transition("Next")
  }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It does. Updated!

ExternalStatePlugin(
ExternalStateHandler(
ref = "my-action",
handlerFunction = ExternalStateHandler.Function { state, options, transition ->
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.

Ditto about using trailing lambda syntax as suggested implementation format if it works


player.hooks.flowController.tap(this.name, (flowController) => {
flowController.hooks.flow.tap(this.name, (flow) => {
flow.hooks.transition.tap(this.name, (fromState, toNamedState) => {
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.

We either need to port these over or add a followup to get rid of the setTimeout: https://github.com/player-ui/player/blob/main/plugins/external-action/core/src/index.ts#L39

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Update! I also tried to remove the param stuff from the web docs.

@KVSRoyal KVSRoyal marked this pull request as ready for review April 9, 2026 16:33
@KVSRoyal KVSRoyal requested a review from a team as a code owner April 9, 2026 16:33
@KVSRoyal KVSRoyal requested review from a team as code owners April 9, 2026 16:33
@KVSRoyal KVSRoyal merged commit 661488a into player-1-dot-zero Apr 14, 2026
23 of 24 checks passed
@KVSRoyal KVSRoyal deleted the external-action branch April 14, 2026 20:42
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.

6 participants