From a73b798e5f9f54ecf8a62c19605005ee535b45dd Mon Sep 17 00:00:00 2001 From: Bhanu Satyani Date: Tue, 17 Feb 2026 13:59:24 -0500 Subject: [PATCH 1/2] feat(iOS): add beforeTransition hook to Flow and parity test Expose the flow's beforeTransition hook on iOS so callers can observe or modify the navigation state before a transition, matching JVM behaviour. --- ios/core/Sources/Types/Core/Flow.swift | 10 +++- .../Sources/Types/Core/NavigationStates.swift | 3 ++ ios/core/Sources/Types/Hooks/Hook.swift | 36 +++++++++++++ ios/core/Tests/FlowStateTests.swift | 50 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) 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..107480590 100644 --- a/ios/core/Sources/Types/Hooks/Hook.swift +++ b/ios/core/Sources/Types/Hooks/Hook.swift @@ -108,6 +108,42 @@ 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 (as JSValue) 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. + */ +public class WaterfallHook2: BaseJSHook { + /** + Attach a closure to the hook. When the hook is fired in the JS runtime, the handler receives + the state as JSValue and the transition value as String. The handler must return the state + as JSValue (typically the same reference to pass through, or a modified state). + - parameters: + - hook: A function (state, transitionValue) -> state JSValue + */ + public func tap(_ hook: @escaping (JSValue, String) -> JSValue) { + let tapMethod: @convention(block) (JSValue?, JSValue?) -> JSValue? = { value, value2 in + guard let val = value, let val2 = value2 else { return nil } + let transitionValue = val2.toString() ?? "" + return hook(val, transitionValue) + } + self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any]) + } + + /** + Typed overload matching JVM's beforeTransition: (NavigationFlowState, String) -> NavigationFlowState. + The handler receives the current state as NavigationBaseState? and the transition value; + return the state (pass-through or modified) or nil to pass through the original state. + Use this to avoid ambiguous use of tap when you want a typed state. + */ + public func tapTyped(_ hook: @escaping (NavigationBaseState?, String) -> NavigationBaseState?) { + tap { state, transitionValue in + let typedState = NavigationBaseState.createInstance(value: state) + return hook(typedState, transitionValue)?.jsValue ?? state + } + } +} + /** 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..86ccd4b2b 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.tapTyped { 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) + } } From 44d01bd379d2b180a58dec39b7aa772605375cb8 Mon Sep 17 00:00:00 2001 From: Bhanu Satyani Date: Mon, 23 Feb 2026 12:11:11 -0500 Subject: [PATCH 2/2] Use single tap for beforeTransition like afterTransition --- ios/core/Sources/Types/Hooks/Hook.swift | 27 ++++++++----------------- ios/core/Tests/FlowStateTests.swift | 2 +- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/ios/core/Sources/Types/Hooks/Hook.swift b/ios/core/Sources/Types/Hooks/Hook.swift index 107480590..4a806f2a0 100644 --- a/ios/core/Sources/Types/Hooks/Hook.swift +++ b/ios/core/Sources/Types/Hooks/Hook.swift @@ -110,38 +110,27 @@ 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 (as JSValue) so the JS runtime can pass it to the next tap or use it as the result. + 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 state as JSValue and the transition value as String. The handler must return the state - as JSValue (typically the same reference to pass through, or a modified state). + 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 JSValue + - hook: A function (state, transitionValue) -> state (or nil to pass through) */ - public func tap(_ hook: @escaping (JSValue, String) -> JSValue) { + 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() ?? "" - return hook(val, transitionValue) + 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]) } - - /** - Typed overload matching JVM's beforeTransition: (NavigationFlowState, String) -> NavigationFlowState. - The handler receives the current state as NavigationBaseState? and the transition value; - return the state (pass-through or modified) or nil to pass through the original state. - Use this to avoid ambiguous use of tap when you want a typed state. - */ - public func tapTyped(_ hook: @escaping (NavigationBaseState?, String) -> NavigationBaseState?) { - tap { state, transitionValue in - let typedState = NavigationBaseState.createInstance(value: state) - return hook(typedState, transitionValue)?.jsValue ?? state - } - } } /** diff --git a/ios/core/Tests/FlowStateTests.swift b/ios/core/Tests/FlowStateTests.swift index 86ccd4b2b..769cae142 100644 --- a/ios/core/Tests/FlowStateTests.swift +++ b/ios/core/Tests/FlowStateTests.swift @@ -114,7 +114,7 @@ class FlowStateTests: XCTestCase { player.hooks?.flowController.tap { flowController in flowController.hooks.flow.tap { flow in - flow.hooks.beforeTransition.tapTyped { state, transitionValue in + flow.hooks.beforeTransition.tap { state, transitionValue in pendingTransition = (flow.currentState, transitionValue) return state }