ExternalActionPlugin & ExternalActionViewModifierPlugin use array of match-handler pairs#829
ExternalActionPlugin & ExternalActionViewModifierPlugin use array of match-handler pairs#829KVSRoyal wants to merge 12 commits intoexternal-actionfrom
ExternalActionPlugin & ExternalActionViewModifierPlugin use array of match-handler pairs#829Conversation
|
@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:
|
|
Claude finished @KVSRoyal's task in 1m 59s —— View job PR Review
Overall, this is a solid PR. The registry-based dispatch is clean. The specificity logic is well-tested. Below are findings across the layers. How It WorkssequenceDiagram
participant SW as Swift/SwiftUI
participant JS as JS Core (ExternalActionPlugin)
participant R as Registry
SW->>JS: apply() with [match, handler] pairs
JS->>JS: validate each match has ref
JS->>R: set(match, handler) for each valid pair
Note over R: More specific matches win
JS->>JS: tap flow.transition hook
JS->>R: get(externalState)
R-->>JS: best matching handler
JS->>SW: invoke handler callback
SW->>JS: transition(value)
FindingsBug —
|
| Type | File |
|---|---|
ExternalStateHandler.Match |
ios/Sources/ExternalActionPlugin.swift:14 |
ExternalStateViewModifierHandler.Match |
swiftui/Sources/ExternalActionViewModifierPlugin.swift:9 |
Both are [String: Any]. They live in separate modules, so sharing is not trivial. But the names diverge from the TS ExternalActionMatch type. A shared typealias ExternalActionMatch = [String: Any] in a common module would help.
Minor — ExternalActionMatch type comment is slightly off
File: core/src/index.ts:18
The comment says the new form "overrides any index signature leniency." This is correct. If NavigationFlowExternalState has a [key: string]: any index signature, then the old Pick<..., "ref"> form could allow {} through. The new { ref: string } literal form is stricter and the intent is clearer. No change needed, but the comment could be more direct:
// { ref: string } is a plain object type (no index signature).
// Prevents TypeScript from accepting {} when NavigationFlowExternalState has [key: string]: any.Minor — Missing space in demo code
File: ios/demo/Sources/FlowManagerView.swift:40
match: ["ref":"test-1"], // ← missing space after colonShould be ["ref": "test-1"].
Minor — Test assertions could be tighter
File: core/src/__tests__/index.test.ts:527-530
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("An external action match is missing the 'ref' property")
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("This handler will be ignored")
);Both strings appear in the same warn message. The two toHaveBeenCalledWith checks don't prove they came from the same call. A single expect.stringContaining with the full message (or using toHaveBeenNthCalledWith) would be more precise.
Summary
| Area | Status |
|---|---|
| Core TS logic | ✅ Clean |
| TS type safety | ✅ Improved |
| Swift API shape | ✅ Good |
| SwiftUI error cleanup | isExternalState not reset on throw |
Swift ref validation |
|
| Test coverage | ✅ Good, one test still skipped |
| Demo code | ✅ Works, minor style nit |
The core change is well-designed. Fix the error-state cleanup in the SwiftUI plugin (and unskip the test) before merging.
|
@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:
|
|
Claude finished @KVSRoyal's task in 2m 10s —— View job PR ReviewAll four items from the prior review are fixed. Here is what changed and what is new. How It WorkssequenceDiagram
participant Swift
participant JS as JS Core
participant R as Registry
Swift->>Swift: init() — throws if match has no ref
Swift->>JS: apply() with [match, handler] pairs
JS->>JS: skip any match without ref (+ warn)
JS->>R: set(match, handler) per valid pair
Note over R: More specific matches win
JS->>JS: tap flow.transition hook
JS->>R: get(externalState)
R-->>JS: best matching handler
JS->>Swift: invoke callback
Swift->>JS: transition(value)
Fixed from Prior Review
New FindingsMedium —
|
| Area | Status |
|---|---|
| Core TS logic | ✅ Clean |
| TS type safety | ✅ Improved |
| Swift API shape | ✅ Good |
| SwiftUI error cleanup | ✅ Fixed |
Swift ref validation at init |
✅ Fixed |
@Published thread safety |
|
| Demo error handling | try? drops plugin silently |
| Test coverage | ✅ Good — all tests enabled |
Fix the @Published threading issue before merging. The rest are minor.
|
@claude Any remaining feedback? Avoid nit feedback. |
|
Claude finished @KVSRoyal's task in 1m 17s —— View job
Remaining FindingAll prior items are fixed. One thread-safety gap remains. Bug —
|
| } | ||
|
|
||
| // swiftlint:disable:next force_try | ||
| private let externalActionPlugin = try! ExternalActionViewModifierPlugin<ExternalActionSheetModifier>(handlers: [ |
There was a problem hiding this comment.
Consider removing the swiftlint disable and wrapping this in a do/catch with fatalError
instead of try!. A bare try! crash gives no context about what went wrong — a fatalError with
the error message makes debugging much easier.
| self?.isExternalAction = false | ||
| self?.state = nil | ||
| } | ||
| self?.content = nil |
There was a problem hiding this comment.
should self?.content = nil also in withAnimation block?
Also, Task { @mainactor ... } here might cause a race condition for unit tests (which could fail). Moreover, there is one outer Task and then another nested Task, might more likely have delays.
There was a problem hiding this comment.
It wasn't in the block before, so I wasn't planning on moving it. I didn't want to mess with that logic too much.
I think the content might not be part of the animation because the view itself (content is a view) might have an animation applied though?
Also, Task { @mainactor ... } here might cause a race condition for unit tests (which could fail). Moreover, there is one outer Task and then another nested Task, might more likely have delays.
Thought I got rid of that second task. Will remove.
I'm not sure what to do about the race condition for unit tests. It's important that the stuff happens async (so it's not blocking) on the main thread (because the published changes might affect the UI). Do you have any recommendations?
ios/demo/Sources/App.swift
Outdated
| BeaconPlugin<DefaultBeacon> { print(String(describing: $0)) }, | ||
| SwiftUIPendingTransactionPlugin<PendingTransactionPhases>() | ||
| ] + [ | ||
| // swiftlint:disable:next force_try |
There was a problem hiding this comment.
Consider removing the swiftlint disable and wrapping this in a do/catch with fatalError
instead of try!. A bare try! crash gives no context about what went wrong — a fatalError with
the error message makes debugging much easier
ios/demo/Sources/App.swift
Outdated
| ExternalActionHandler( | ||
| match: ["ref": "test-1"], | ||
| handler: { _, _, _ in | ||
| print("MainView External State triggered") |
There was a problem hiding this comment.
Is this intentional? looks like the app might hang here since no transition?
There was a problem hiding this comment.
Yeah. This one isn't actually used in the demo app as far as I can tell. I've just left it the way I found it.
| let match = handler.match | ||
| if match["ref"] == nil { | ||
| throw ExternalActionPluginError.matchMissingRef(match: match) | ||
| } |
There was a problem hiding this comment.
having a question regarding this: if any single handler in the array has a nil ref, the entire init fails (no ExternalActionPlugin is created at all), even if the other handlers are valid. Is this expected?
| let match = handler.match | ||
| if match["ref"] == nil { | ||
| throw ExternalActionPluginError.matchMissingRef(match: match) | ||
| } |
There was a problem hiding this comment.
same question: if any single handler in the array has a nil ref, then there will be no ExternalActionViewModifierPlugin created, is this expected?
There was a problem hiding this comment.
That's the current behaviour on ios/android, yeah. We can revise it. Let's sync off of Github
| self?.isExternalAction = false | ||
| self?.state = nil | ||
| } | ||
| self?.content = nil |
There was a problem hiding this comment.
self?.content should be in withAnimation block? as content could be nil before the animation finished. then you might see a blank content.
| } | ||
| }) | ||
| else { return nil } | ||
| return promise |
There was a problem hiding this comment.
now updateUI is protected. however, the transition part is still not protected. Those published var (self?.isExternalAction = false, self?.state = nil, self?.content = nil) could be updated on a wrong thread
so you would apply the same pattern to it (like you previously has two Task {@mainactor in }, you would need two here as well).
if Thread.isMainThread {
//do reset
} else {
DispatchQueue.main.sync(execute: // do reset)
}
What
This is the ios/swiftui part of #817. This does not include a docs update. I plan to update the multi-platform docs in a separate PR once the APIs are solidified.
This PR changes the
ExternalActionPlugin&ExternalActionViewModifierPluginto accept an array of match-handler pairs. These pairs are passed through to the core.ExternalActionPluginBefore / AfterExternalActionViewModifierPluginBefore / After(For those unfamiliar, this version of the plugin allows the user to also provide the External Action view through the same API.)
Additionally:
JavaScriptCorebypasses TypeScripty safety. It was allowing[:]as a valid match. Valid matches should require at least["ref" : something]. I added a check in the core layer to throw out match-handler pairs without arefin them.Why
Deterministic Overrides: Users want predictable behaviour when multiple plugins or handlers are registered. Registerying handlers by
matchensures that:Change Type (required)
Indicate the type of change your pull request is:
patchminormajorN/A(part of 1.0)Does your PR have any documentation updates?