feat: Add form lifecycle event hooks#515
Conversation
Added flag to track whether formDismissed has been fired for current form session. This prevents duplicate events when both dismissForm() and destroyWebView() are called during shutdown. Flag is reset when a new form is presented. Addresses: #515 (comment) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Adds lifecycle event callbacks for in-app forms to enable tracking form interactions in third-party analytics platforms (Amplitude, Segment, etc.). - `registerFormLifecycleHandler(_:)` - Register callback for form events - `unregisterFormLifecycleHandler()` - Remove callback - `isFormLifecycleHandlerRegistered` - Check registration status - `formShown` - Fires when form is presented - `formDismissed` - Fires when form is dismissed (any reason) - `formCTAClicked` - Fires when user taps a CTA button - Events fire on main thread via @mainactor - Handler persists across multiple `registerForInAppForms()` calls - Follows existing `registerDeepLinkHandler()` pattern - Optional feature - forms work without handler Also fixes a bug where forms with empty deep link URLs would fail silently when CTA buttons were clicked. Now handles empty URLs gracefully and still fires lifecycle events. \`\`\`swift KlaviyoSDK().registerFormLifecycleHandler { event in switch event { case .formShown: Analytics.track("Klaviyo Form Shown") case .formDismissed: Analytics.track("Klaviyo Form Dismissed") case .formCTAClicked: Analytics.track("Klaviyo Form CTA Clicked") } } \`\`\` Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixes 3 critical issues identified in code review: 1. **Security: Remove real API key from example** - Reverted "Xr5bFG" to placeholder "YOUR_PUBLIC_API_KEY" - Prevents accidental credential exposure in public repository 2. **Reliability: Fire formDismissed for all dismissal paths** - Added lifecycle event invocation to destroyWebView() - Ensures timeout-based and programmatic dismissals fire events - Matches documentation promise: "fires for all dismissal types" - Only fires if form is actually visible (presentingViewController != nil) 3. **Robustness: Handle missing/null deep link fields** - Changed from decode() to decodeIfPresent() for ios field - Gracefully handles null, missing, or empty URL values - Prevents decode failures from dropping lifecycle events - Maintains backward compatibility with valid URLs These changes improve security, ensure complete event coverage, and make the bridge more resilient to varied JavaScript payloads.
Added flag to track whether formDismissed has been fired for current form session. This prevents duplicate events when both dismissForm() and destroyWebView() are called during shutdown. Flag is reset when a new form is presented. Addresses: #515 (comment) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Removed unnecessary isFormLifecycleHandlerRegistered API to simplify the public interface. Registration can't fail, and users don't need to check if a handler is registered. This aligns with other SDK features that don't expose similar "is registered" properties. Changes: - Removed public isFormLifecycleHandlerRegistered property - Removed internal hasFormLifecycleHandler property - Updated tests to verify behavior instead of checking property - Removed example usage from AppDelegate - Updated README documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Remove `package` access modifier from lifecycle handler methods (internal is sufficient, both callers are in KlaviyoForms) - Move Form Lifecycle Handler MARK section below Properties & Initializer to reflect its secondary importance - Add privacy: .public to unknown message handler warning log - Remove raw JSON log on decode failure (potential PII in payload) - Strip verbose print statements and Amplitude/Mixpanel references from example app - Remove redundant Thread.isMainThread assertion (@mainactor already guarantees main thread) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
91009d9 to
714851c
Compare
| .registerForInAppForms() // STEP2A: register for in app forms | ||
| .registerGeofencing() // STEP2B: register for in geofencing | ||
| .registerFormLifecycleHandler { event in | ||
| // STEP2C: [OPTIONAL] Register for form lifecycle events to track form interactions |
There was a problem hiding this comment.
// STEP2C
does this correspond to something, like a reference in the readme? If not, can you remove it?
There was a problem hiding this comment.
It's a step by step order of integration in the example app that was existing and this just built on that. I think it's useful when someone is looking to integrate.
Passes the ID of the active form alongside the lifecycle event so callers can attribute analytics events to a specific form. Changes: - IAFNativeBridgeEvent: decode formId from formWillAppear payload - IAFLifecycleEvent: add associated value to .present case - IAFPresentationManager: track currentFormId; pass it to handler - KlaviyoSDK+Forms: update public registerFormLifecycleHandler signature to (FormLifecycleEvent, String?) -> Void - Update doc comment examples and AppDelegate sample Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
belleklaviyo
left a comment
There was a problem hiding this comment.
looks good, and thanks for adding good tests for this
Replaces the raw String? formId parameter in the lifecycle handler closure with a FormContext struct. This keeps the callback signature stable as new fields are added (e.g. step, formType) without breaking existing integrations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@belleklaviyo @ab1470 made a minor change and included an object in the closure to send the form id. Made this an object so that in a future where we may want to add more properties its easy to do. |
All praise to the frenchman! |
Sources/KlaviyoForms/InAppForms/Models/IAFNativeBridgeEvent.swift
Outdated
Show resolved
Hide resolved
| startProfileObservation() | ||
| case .present: | ||
| case let .present(formId): | ||
| currentFormContext = FormContext(formId: formId) |
There was a problem hiding this comment.
Form context overwritten before verifying successful presentation
Low Severity
currentFormContext is unconditionally set in handleFormEvent(.present(formId)) before presentForm() runs. If the presentation is rejected (e.g., another Klaviyo form is already showing), the context for the currently-displayed form gets overwritten. Subsequent formCTAClicked or formDismissed callbacks would then carry the wrong formId. Moving the context assignment into presentForm() right before the actual present() call would prevent this.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>


Summary
Adds lifecycle event callbacks for in-app forms to enable tracking form interactions in third-party analytics platforms.
New Public API
Events
formShown— Fires immediately before the form view controller is presentedformDismissed— Fires when form is dismissed (user action, timeout, or programmatic)formCTAClicked— Fires when user taps a CTA button (always fires, even if no deep link is configured)FormContext
Each event is accompanied by a
FormContextstruct carrying metadata about the form. Currently exposesformId; new fields (e.g. step, form type, dismissal reason) can be added without changing the callback signature.Implementation Details
@MainActorformIddecoded from theformWillAppearJS bridge message and threaded through to the callbackformCTAClickedalways fires even when no deep link URL is configuredformDismissedevents are suppressed viahasInvokedDismissedflag (covers timeout, programmatic, and user-initiated paths)registerDeepLinkHandler()pattern for consistencyNew Files
Sources/KlaviyoForms/InAppForms/Models/FormLifecycleEvent.swift— public event enumSources/KlaviyoForms/InAppForms/Models/FormContext.swift— public context structTests/KlaviyoFormsTests/FormLifecycleHandlerTests.swift— test suiteModified Files
Sources/KlaviyoForms/KlaviyoSDK+Forms.swift— publicregisterFormLifecycleHandler(_:)/unregisterFormLifecycleHandler()Sources/KlaviyoForms/InAppForms/IAFPresentationManager.swift— handler storage,currentFormContext, invocationSources/KlaviyoForms/InAppForms/IAFWebViewModel.swift— yieldsformIdthrough lifecycle streamSources/KlaviyoForms/InAppForms/Models/IAFNativeBridgeEvent.swift—FormWillAppearPayloaddecodesformId;IAFLifecycleEvent.presentcarriesformIdExamples/KlaviyoSwiftExamples/Shared/AppDelegate.swift— updated exampleTest Plan
Breaking Changes
None — purely additive.
🤖 Generated with Claude Code