Skip to content

Commit d16eab9

Browse files
evan-masseauclaude
andauthored
feat: Add form lifecycle event bridge for in-app forms (#334) (#338)
* feat: Add form lifecycle event bridge for in-app forms (#334) * fix: batch dependabot security updates for transitive deps (#335) fix: batch security updates for transitive dependencies Consolidates 6 dependabot security PRs into a single lockfile update: - handlebars 4.7.8→4.7.9 (8 security advisories) - picomatch 2.3.1→2.3.2 (CVE-2026-33671, CVE-2026-33672) - fast-xml-parser 4.5.3→4.5.5 (prototype pollution, entity expansion) - flatted 3.3.3→3.4.2 (CWE-1321) - tar 7.5.7→7.5.11 (symlink escape via drive-relative paths) - basic-ftp 5.1.0→5.2.0 (skip invalid filenames) All are transitive dependencies (lockfile-only, no source changes). Closes #331, closes #330, closes #328, closes #325, closes #322, closes #316 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add form lifecycle event bridge for in-app forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: configure native SDK deps for form lifecycle hooks testing Android SDK: 0a48d79980dc3f276422cf9c42ff145eb39f364a iOS SDK branch: feat/form-lifecycle-hooks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: unregister native lifecycle handler during re-registration When registerFormLifecycleHandler is called again without explicit cleanup, the re-registration path now tears down the native handler before re-registering, matching the cleanup function's behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clear native mock between lifecycle tests to match re-registration behavior The unregister test was failing because prior tests left the native unregisterFormLifecycleHandler mock dirty — the re-registration path now calls native unregister, so the mock accumulates calls across tests. Also added assertion that re-registration calls native unregister. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(forms): validate required bridge message fields (MAGE-484) (#340) * fix(forms): throw on missing required fields in bridge message parsing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct JSDoc and add integration tests for lifecycle event parsing Fix JSDoc that said "logs a warning" when the code actually uses console.error. Add integration tests to index.test.tsx verifying that parseFormLifecycleEvent is correctly wired into the native event listener (invalid events filtered, valid events forwarded, deepLinkUrl defaults). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(forms): validate deepLinkUrl as required on CTA events, normalize log level to warn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(forms): allow empty buttonLabel on CTA events A CTA button with no text label is still a valid click event. Only formId and formName require non-empty strings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(forms): remove internal implementation details from public API docs Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(forms): default buttonLabel to empty string instead of rejecting Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f34e9f4 commit d16eab9

13 files changed

Lines changed: 968 additions & 32 deletions

File tree

android/gradle.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ android.useAndroidX=true
1717
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
1818
# org.gradle.parallel=true
1919
#Tue Dec 19 15:08:27 EST 2023
20-
KlaviyoReactNativeSdk_klaviyoAndroidSdkVersion=4.3.1
20+
# saved: 4.3.1
21+
KlaviyoReactNativeSdk_klaviyoAndroidSdkVersion=rel~4.4.0-SNAPSHOT
2122
KlaviyoReactNativeSdk_kotlinVersion=1.8.0
2223
KlaviyoReactNativeSdk_minSdkVersion=23
2324
KlaviyoReactNativeSdk_targetSdkVersion=36

android/src/main/java/com/klaviyoreactnativesdk/KlaviyoReactNativeSdkModule.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.facebook.react.bridge.ReactMethod
88
import com.facebook.react.bridge.ReadableMap
99
import com.facebook.react.bridge.ReadableType
1010
import com.facebook.react.bridge.UiThreadUtil
11+
import com.facebook.react.bridge.WritableMap
12+
import com.facebook.react.modules.core.DeviceEventManagerModule
1113
import com.klaviyo.analytics.Klaviyo
1214
import com.klaviyo.analytics.model.Event
1315
import com.klaviyo.analytics.model.EventKey
@@ -19,9 +21,12 @@ import com.klaviyo.core.MissingKlaviyoModule
1921
import com.klaviyo.core.Registry
2022
import com.klaviyo.core.config.Config
2123
import com.klaviyo.core.utils.AdvancedAPI
24+
import com.klaviyo.forms.FormLifecycleEvent
2225
import com.klaviyo.forms.FormsProvider
2326
import com.klaviyo.forms.InAppFormsConfig
2427
import com.klaviyo.forms.registerForInAppForms
28+
import com.klaviyo.forms.registerFormLifecycleHandler
29+
import com.klaviyo.forms.unregisterFormLifecycleHandler
2530
import com.klaviyo.forms.unregisterFromInAppForms
2631
import com.klaviyo.location.GeofencingProvider
2732
import com.klaviyo.location.LocationManager
@@ -40,6 +45,15 @@ class KlaviyoReactNativeSdkModule(
4045
private const val PROPERTIES = "properties"
4146
}
4247

48+
private fun sendEvent(
49+
eventName: String,
50+
params: WritableMap?,
51+
) {
52+
reactContext
53+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
54+
.emit(eventName, params)
55+
}
56+
4357
override fun getName(): String = NAME
4458

4559
override fun getConstants(): MutableMap<String, Any> =
@@ -288,4 +302,56 @@ class KlaviyoReactNativeSdkModule(
288302

289303
Klaviyo.createEvent(event = klaviyoEvent)
290304
}
305+
306+
@ReactMethod
307+
fun registerFormLifecycleHandler() {
308+
UiThreadUtil.runOnUiThread {
309+
try {
310+
Klaviyo.registerFormLifecycleHandler { event ->
311+
val params =
312+
Arguments.createMap().apply {
313+
putString("formId", event.formId)
314+
putString("formName", event.formName)
315+
when (event) {
316+
is FormLifecycleEvent.FormShown -> {
317+
putString("type", "formShown")
318+
}
319+
320+
is FormLifecycleEvent.FormDismissed -> {
321+
putString("type", "formDismissed")
322+
}
323+
324+
is FormLifecycleEvent.FormCtaClicked -> {
325+
putString("type", "formCtaClicked")
326+
putString("buttonLabel", event.buttonLabel)
327+
putString("deepLinkUrl", event.deepLinkUrl.toString())
328+
}
329+
}
330+
}
331+
332+
sendEvent("FormLifecycleEvent", params)
333+
}
334+
} catch (e: MissingKlaviyoModule) {
335+
Registry.log.error("Forms module is not available", e)
336+
}
337+
}
338+
}
339+
340+
@ReactMethod
341+
fun unregisterFormLifecycleHandler() {
342+
UiThreadUtil.runOnUiThread {
343+
try {
344+
Klaviyo.unregisterFormLifecycleHandler()
345+
} catch (e: MissingKlaviyoModule) {
346+
Registry.log.error("Forms module is not available", e)
347+
}
348+
}
349+
}
350+
351+
// Required by NativeEventEmitter on the JS side
352+
@ReactMethod
353+
fun addListener(eventName: String) {}
354+
355+
@ReactMethod
356+
fun removeListeners(count: Int) {}
291357
}

example/ios/Podfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ target 'KlaviyoReactNativeSdkExample' do
2828
use_frameworks! :linkage => :static
2929

3030
# Insert override klaviyo-swift-sdk pods below this line when needed
31+
pod 'KlaviyoCore', :git => 'https://github.com/klaviyo/klaviyo-swift-sdk.git', :branch => 'rel/5.3.0'
32+
pod 'KlaviyoSwift', :git => 'https://github.com/klaviyo/klaviyo-swift-sdk.git', :branch => 'rel/5.3.0'
33+
pod 'KlaviyoForms', :git => 'https://github.com/klaviyo/klaviyo-swift-sdk.git', :branch => 'rel/5.3.0'
34+
pod 'KlaviyoLocation', :git => 'https://github.com/klaviyo/klaviyo-swift-sdk.git', :branch => 'rel/5.3.0'
3135

3236
# Setup permissions for react-native-permissions
3337
setup_permissions([

example/ios/Podfile.lock

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ PODS:
1010
- hermes-engine/Pre-built (= 0.81.5)
1111
- hermes-engine/Pre-built (0.81.5)
1212
- klaviyo-react-native-sdk (2.3.1):
13-
- KlaviyoForms (= 5.2.2)
14-
- KlaviyoLocation (= 5.2.2)
15-
- KlaviyoSwift (= 5.2.2)
13+
- KlaviyoForms
14+
- KlaviyoLocation
15+
- KlaviyoSwift
1616
- React-Core
17-
- KlaviyoCore (5.2.2):
17+
- KlaviyoCore (5.3.0):
1818
- AnyCodable-FlightSchool
19-
- KlaviyoForms (5.2.2):
20-
- KlaviyoSwift (~> 5.2.2)
21-
- KlaviyoLocation (5.2.2):
22-
- KlaviyoSwift (~> 5.2.2)
23-
- KlaviyoSwift (5.2.2):
19+
- KlaviyoForms (5.3.0):
20+
- KlaviyoSwift (~> 5.3.0)
21+
- KlaviyoLocation (5.3.0):
22+
- KlaviyoSwift (~> 5.3.0)
23+
- KlaviyoSwift (5.3.0):
2424
- AnyCodable-FlightSchool
25-
- KlaviyoCore (~> 5.2.2)
25+
- KlaviyoCore (~> 5.3.0)
2626
- KlaviyoSwiftExtension (5.2.1)
2727
- RCT-Folly (2024.11.18.00):
2828
- boost
@@ -2396,6 +2396,10 @@ DEPENDENCIES:
23962396
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
23972397
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
23982398
- klaviyo-react-native-sdk (from `../..`)
2399+
- KlaviyoCore (from `https://github.com/klaviyo/klaviyo-swift-sdk.git`, branch `rel/5.3.0`)
2400+
- KlaviyoForms (from `https://github.com/klaviyo/klaviyo-swift-sdk.git`, branch `rel/5.3.0`)
2401+
- KlaviyoLocation (from `https://github.com/klaviyo/klaviyo-swift-sdk.git`, branch `rel/5.3.0`)
2402+
- KlaviyoSwift (from `https://github.com/klaviyo/klaviyo-swift-sdk.git`, branch `rel/5.3.0`)
23992403
- KlaviyoSwiftExtension
24002404
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
24012405
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
@@ -2469,10 +2473,6 @@ DEPENDENCIES:
24692473
SPEC REPOS:
24702474
trunk:
24712475
- AnyCodable-FlightSchool
2472-
- KlaviyoCore
2473-
- KlaviyoForms
2474-
- KlaviyoLocation
2475-
- KlaviyoSwift
24762476
- KlaviyoSwiftExtension
24772477
- SocketRocket
24782478

@@ -2494,6 +2494,18 @@ EXTERNAL SOURCES:
24942494
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
24952495
klaviyo-react-native-sdk:
24962496
:path: "../.."
2497+
KlaviyoCore:
2498+
:branch: rel/5.3.0
2499+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2500+
KlaviyoForms:
2501+
:branch: rel/5.3.0
2502+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2503+
KlaviyoLocation:
2504+
:branch: rel/5.3.0
2505+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2506+
KlaviyoSwift:
2507+
:branch: rel/5.3.0
2508+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
24972509
RCT-Folly:
24982510
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
24992511
RCTDeprecation:
@@ -2627,6 +2639,20 @@ EXTERNAL SOURCES:
26272639
Yoga:
26282640
:path: "../node_modules/react-native/ReactCommon/yoga"
26292641

2642+
CHECKOUT OPTIONS:
2643+
KlaviyoCore:
2644+
:commit: 082f13c3c0696cfc973c3f63907d35e0bf26b9ff
2645+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2646+
KlaviyoForms:
2647+
:commit: 082f13c3c0696cfc973c3f63907d35e0bf26b9ff
2648+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2649+
KlaviyoLocation:
2650+
:commit: 082f13c3c0696cfc973c3f63907d35e0bf26b9ff
2651+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2652+
KlaviyoSwift:
2653+
:commit: 082f13c3c0696cfc973c3f63907d35e0bf26b9ff
2654+
:git: https://github.com/klaviyo/klaviyo-swift-sdk.git
2655+
26302656
SPEC CHECKSUMS:
26312657
AnyCodable-FlightSchool: 261cbe76757802b17d471b9059b21e6fa5edf57b
26322658
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
@@ -2636,13 +2662,13 @@ SPEC CHECKSUMS:
26362662
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
26372663
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
26382664
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
2639-
klaviyo-react-native-sdk: 308ea86b6b48d10be7127aba51f5800167122b7d
2640-
KlaviyoCore: 0104cec11bbc1c5838f9a232ea420d15a43bbdfb
2641-
KlaviyoForms: 4be515497a79df4876b950eb1de5742dc42754e0
2642-
KlaviyoLocation: edb041d083080aacdf052bc2f6a3b708ec76a721
2643-
KlaviyoSwift: 596bf5471ec37eed2773ffc603b3026895b93bb8
2665+
klaviyo-react-native-sdk: 8ee6035a0dff7eab0de01cbf128883c330df0879
2666+
KlaviyoCore: 0d27552664bb46b171e8256b92a3af94e7261a00
2667+
KlaviyoForms: 589f91cb245dbf78f657cbc46a1d3dab47bef601
2668+
KlaviyoLocation: 9ba7810b0cbe1205dad0903c42dc420ba8dd6025
2669+
KlaviyoSwift: 844dda154601d17f1b6a95dca8e5e6dd918b4664
26442670
KlaviyoSwiftExtension: 310a32489eeca1b2a540903a55028b1ffef6b070
2645-
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
2671+
RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f
26462672
RCTDeprecation: 5eb1d2eeff5fb91151e8a8eef45b6c7658b6c897
26472673
RCTRequired: cebcf9442fc296c9b89ac791dfd463021d9f6f23
26482674
RCTTypeSafety: b99aa872829ee18f6e777e0ef55852521c5a6788
@@ -2708,8 +2734,8 @@ SPEC CHECKSUMS:
27082734
ReactCommon: 5cfd842fcd893bb40fc835f98fadc60c42906a20
27092735
RNPermissions: 86933bcc014e7fa7d77e09b7b0b0b858287d611f
27102736
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
2711-
Yoga: 1e91d83a5286cfd3b725eade59274c92270540d4
2737+
Yoga: cc4a6600d61e4e9276e860d4d68eebb834a050ba
27122738

2713-
PODFILE CHECKSUM: 03bdd0145df6393c4c141686be3a82ab1eb4ee5d
2739+
PODFILE CHECKSUM: d7648bc76ebfe99a4d62dbb22c4635482634202a
27142740

27152741
COCOAPODS: 1.15.2

example/src/KlaviyoReactWrapper.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
type Profile,
66
ProfileProperty,
77
type FormConfiguration,
8+
type FormLifecycleEvent,
9+
FormLifecycleEventType,
810
} from 'klaviyo-react-native-sdk';
911

1012
import {
@@ -30,6 +32,28 @@ export const initialize = async () => {
3032
// Alternate iOS Installation Step 3
3133
// Initialize the SDK with public key, if initializing from React Native
3234
Klaviyo.initialize('YOUR_KLAVIYO_PUBLIC_API_KEY');
35+
36+
// Register form lifecycle handler to log events
37+
Klaviyo.registerFormLifecycleHandler((event: FormLifecycleEvent) => {
38+
const nameInfo = event.formName ? ` (${event.formName})` : '';
39+
console.log(
40+
`[Form Lifecycle] ${event.type}: Form ${event.formId}${nameInfo}`
41+
);
42+
43+
switch (event.type) {
44+
case FormLifecycleEventType.Shown:
45+
console.log(`Form ${event.formId}${nameInfo} is being shown`);
46+
break;
47+
case FormLifecycleEventType.Dismissed:
48+
console.log(`Form ${event.formId}${nameInfo} was dismissed`);
49+
break;
50+
case FormLifecycleEventType.CtaClicked:
51+
console.log(
52+
`Form ${event.formId}${nameInfo} CTA was clicked: ${event.buttonLabel}, deep link: ${event.deepLinkUrl}`
53+
);
54+
break;
55+
}
56+
});
3357
} catch (e: any) {
3458
console.log(e.message, e.code);
3559
}

ios/KlaviyoBridge.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,38 @@ public class KlaviyoBridge: NSObject {
9292
#endif
9393
}
9494

95+
@MainActor
96+
@objc
97+
public static func registerFormLifecycleHandler(callback: @escaping ([String: Any]) -> Void) {
98+
#if canImport(KlaviyoForms)
99+
KlaviyoSDK().registerFormLifecycleHandler { event in
100+
var params: [String: Any] = [
101+
"formId": event.formId as Any,
102+
"formName": event.formName as Any
103+
]
104+
switch event {
105+
case .formShown:
106+
params["type"] = "formShown"
107+
case .formDismissed:
108+
params["type"] = "formDismissed"
109+
case let .formCtaClicked(_, _, buttonLabel, deepLinkUrl):
110+
params["type"] = "formCtaClicked"
111+
params["buttonLabel"] = buttonLabel as Any
112+
params["deepLinkUrl"] = deepLinkUrl.absoluteString as Any
113+
}
114+
callback(params)
115+
}
116+
#endif
117+
}
118+
119+
@MainActor
120+
@objc
121+
public static func unregisterFormLifecycleHandler() {
122+
#if canImport(KlaviyoForms)
123+
KlaviyoSDK().unregisterFormLifecycleHandler()
124+
#endif
125+
}
126+
95127
@MainActor
96128
@objc
97129
public static func registerGeofencing() {

ios/KlaviyoReactNativeSdk.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

2-
#import <React/RCTBridgeModule.h>
2+
#import <React/RCTEventEmitter.h>
33

4-
@interface KlaviyoReactNativeSdk : NSObject <RCTBridgeModule>
4+
@interface KlaviyoReactNativeSdk : RCTEventEmitter <RCTBridgeModule>
55

66
@end

ios/KlaviyoReactNativeSdk.mm

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ + (BOOL)requiresMainQueueSetup {
1414
return YES;
1515
}
1616

17+
- (NSArray<NSString *> *)supportedEvents {
18+
return @[@"FormLifecycleEvent"];
19+
}
20+
1721
// The values here eventually should come from the iOS SDK once exposed there.
1822
- (NSDictionary *)constantsToExport {
1923
return @{
@@ -142,4 +146,22 @@ - (NSDictionary *)constantsToExport {
142146
});
143147
}
144148

149+
RCT_EXPORT_METHOD(registerFormLifecycleHandler) {
150+
__weak __typeof__(self) weakSelf = self;
151+
dispatch_async(dispatch_get_main_queue(), ^{
152+
[KlaviyoBridge registerFormLifecycleHandlerWithCallback:^(NSDictionary *eventData) {
153+
__strong __typeof__(weakSelf) strongSelf = weakSelf;
154+
if (strongSelf) {
155+
[strongSelf sendEventWithName:@"FormLifecycleEvent" body:eventData];
156+
}
157+
}];
158+
});
159+
}
160+
161+
RCT_EXPORT_METHOD(unregisterFormLifecycleHandler) {
162+
dispatch_async(dispatch_get_main_queue(), ^{
163+
[KlaviyoBridge unregisterFormLifecycleHandler];
164+
});
165+
}
166+
145167
@end

klaviyo-react-native-sdk.podspec

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ Pod::Spec.new do |s|
1818
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }
1919

2020
s.dependency "React-Core"
21-
s.dependency "KlaviyoSwift", "5.2.2"
21+
s.dependency "KlaviyoSwift"
2222
# Optional location and forms; included by default, set to 'false' to exclude
2323
if ENV['KLAVIYO_INCLUDE_LOCATION'] != 'false'
24-
s.dependency "KlaviyoLocation", "5.2.2"
24+
s.dependency "KlaviyoLocation"
2525
end
2626
if ENV['KLAVIYO_INCLUDE_FORMS'] != 'false'
27-
s.dependency "KlaviyoForms", "5.2.2"
27+
s.dependency "KlaviyoForms"
2828
end
2929

3030
s.default_subspecs = :none

0 commit comments

Comments
 (0)