diff --git a/ios/core/Sources/Types/Core/Flow.swift b/ios/core/Sources/Types/Core/Flow.swift index ae31cb619..5ec217c1a 100644 --- a/ios/core/Sources/Types/Core/Flow.swift +++ b/ios/core/Sources/Types/Core/Flow.swift @@ -44,11 +44,19 @@ public class Flow: CreatedFromJSValue { */ public init(_ value: JSValue) { self.value = value - hooks = FlowHooks(transition: Hook2(baseValue: value, name: "transition"), afterTransition: Hook(baseValue: value, name: "afterTransition")) + hooks = FlowHooks( + beforeTransition: WaterfallHook2(baseValue: value, name: "beforeTransition"), + transition: Hook2(baseValue: value, name: "transition"), + afterTransition: Hook(baseValue: value, name: "afterTransition") + ) } } public struct FlowHooks { + /// A hook that fires before a transition, with the current state and transition value. The handler receives + /// (state as JSValue, transitionValue as String) and must return the state as JSValue (pass-through or modified). + public var beforeTransition: WaterfallHook2 + /// A hook that fires when transitioning states and giving the old and new states as parameters public var transition: Hook2 diff --git a/ios/core/Sources/Types/Core/NavigationStates.swift b/ios/core/Sources/Types/Core/NavigationStates.swift index 85e4297fb..573460601 100644 --- a/ios/core/Sources/Types/Core/NavigationStates.swift +++ b/ios/core/Sources/Types/Core/NavigationStates.swift @@ -10,6 +10,9 @@ open class NavigationBaseState: CreatedFromJSValue { internal let rawValue: JSValue + /// The backing JSValue. Use when returning this state from a waterfall hook (e.g. beforeTransition). + public var jsValue: JSValue { rawValue } + public static func createInstance(value: JSValue) -> NavigationBaseState { let base = NavigationBaseState(value) switch base.stateType { diff --git a/ios/core/Sources/Types/Hooks/Hook.swift b/ios/core/Sources/Types/Hooks/Hook.swift index 07e3e57b7..4a806f2a0 100644 --- a/ios/core/Sources/Types/Hooks/Hook.swift +++ b/ios/core/Sources/Types/Hooks/Hook.swift @@ -108,6 +108,31 @@ public class Hook2: BaseJSHook where T: CreatedFromJSValue, U: CreatedFrom } } +/** + A waterfall hook with 2 parameters. The handler receives (state, transitionValue) and must return + the state so the JS runtime can pass it to the next tap or use it as the result. + Used for flow hooks like beforeTransition where the return value is the (possibly modified) state. + Aligns with FlowHooks.transition and afterTransition: use tap { state, transitionValue in ... return state }. + */ +public class WaterfallHook2: BaseJSHook { + /** + Attach a closure to the hook. When the hook is fired in the JS runtime, the handler receives + the current state as NavigationBaseState? and the transition value as String. Return the state + (pass-through or modified), or nil to pass through the original state. + - parameters: + - hook: A function (state, transitionValue) -> state (or nil to pass through) + */ + public func tap(_ hook: @escaping (NavigationBaseState?, String) -> NavigationBaseState?) { + let tapMethod: @convention(block) (JSValue?, JSValue?) -> JSValue? = { value, value2 in + guard let val = value, let val2 = value2 else { return nil } + let transitionValue = val2.toString() ?? "" + let typedState = NavigationBaseState.createInstance(value: val) + return hook(typedState, transitionValue)?.jsValue ?? val + } + self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any]) + } +} + /** This class represents an object in the JS runtime that can be tapped into to receive JS events diff --git a/ios/core/Tests/FlowStateTests.swift b/ios/core/Tests/FlowStateTests.swift index 01e150ea5..769cae142 100644 --- a/ios/core/Tests/FlowStateTests.swift +++ b/ios/core/Tests/FlowStateTests.swift @@ -104,4 +104,54 @@ class FlowStateTests: XCTestCase { wait(for: [endStateHit], timeout: 1) } + + /// Mirrors JVM FlowControllerIntegrationTest: beforeTransition, transition, and afterTransition hooks fire as expected. + func testFlowControllerBeforeTransitionHook() { + let player = HeadlessPlayerImpl(plugins: []) + var pendingTransition: (NamedState?, String)? + var completedTransition: (NamedState?, NamedState)? + var flowInstanceAfterTransition: Flow? + + player.hooks?.flowController.tap { flowController in + flowController.hooks.flow.tap { flow in + flow.hooks.beforeTransition.tap { state, transitionValue in + pendingTransition = (flow.currentState, transitionValue) + return state + } + flow.hooks.transition.tap { from, to in + if pendingTransition?.0?.name != from?.name { pendingTransition = nil } + completedTransition = (from, to) + } + flow.hooks.afterTransition.tap { flowInstance in + flowInstanceAfterTransition = flowInstance + } + } + } + + let inProgress = expectation(description: "in progress") + player.hooks?.state.tap { newState in + if newState is InProgressState { inProgress.fulfill() } + } + + player.start(flow: FlowData.COUNTER) { _ in } + + wait(for: [inProgress], timeout: 2) + + XCTAssertNil(pendingTransition) + XCTAssertNil(completedTransition?.0) + XCTAssertEqual((completedTransition?.1)?.name, "VIEW_1") + + do { + try (player.state as? InProgressState)?.controllers?.flow.transition(with: "*") + } catch { + XCTFail("Transition failed: \(error)") + } + + let pendingFrom = pendingTransition?.0?.value as? NavigationFlowViewState + XCTAssertEqual(pendingFrom?.ref, "action") + XCTAssertEqual(pendingTransition?.1, "*") + XCTAssertEqual(completedTransition?.0?.name, "VIEW_1") + XCTAssertEqual((completedTransition?.1)?.name, "END_Done") + XCTAssertNotNil(flowInstanceAfterTransition) + } }