Skip to content

feat: Add form lifecycle event hooks#515

Merged
ajaysubra merged 11 commits intomasterfrom
feat/form-lifecycle-hooks
Mar 6, 2026
Merged

feat: Add form lifecycle event hooks#515
ajaysubra merged 11 commits intomasterfrom
feat/form-lifecycle-hooks

Conversation

@ajaysubra
Copy link
Contributor

@ajaysubra ajaysubra commented Feb 22, 2026

Summary

Adds lifecycle event callbacks for in-app forms to enable tracking form interactions in third-party analytics platforms.

New Public API

// Register handler
KlaviyoSDK().registerFormLifecycleHandler { event, context in
    switch event {
    case .formShown:
        Analytics.track("Form Shown", properties: ["formId": context.formId ?? ""])
    case .formDismissed:
        Analytics.track("Form Dismissed", properties: ["formId": context.formId ?? ""])
    case .formCTAClicked:
        Analytics.track("Form CTA Clicked", properties: ["formId": context.formId ?? ""])
    }
}

// Unregister handler
KlaviyoSDK().unregisterFormLifecycleHandler()

Events

  • formShown — Fires immediately before the form view controller is presented
  • formDismissed — 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 FormContext struct carrying metadata about the form. Currently exposes formId; new fields (e.g. step, form type, dismissal reason) can be added without changing the callback signature.

public struct FormContext: Sendable {
    public let formId: String?
}

Implementation Details

  • ✅ Handler invoked on main thread via @MainActor
  • formId decoded from the formWillAppear JS bridge message and threaded through to the callback
  • formCTAClicked always fires even when no deep link URL is configured
  • ✅ Duplicate formDismissed events are suppressed via hasInvokedDismissed flag (covers timeout, programmatic, and user-initiated paths)
  • ✅ Optional feature — forms work without a registered handler
  • ✅ Follows existing registerDeepLinkHandler() pattern for consistency

New Files

  • Sources/KlaviyoForms/InAppForms/Models/FormLifecycleEvent.swift — public event enum
  • Sources/KlaviyoForms/InAppForms/Models/FormContext.swift — public context struct
  • Tests/KlaviyoFormsTests/FormLifecycleHandlerTests.swift — test suite

Modified Files

  • Sources/KlaviyoForms/KlaviyoSDK+Forms.swift — public registerFormLifecycleHandler(_:) / unregisterFormLifecycleHandler()
  • Sources/KlaviyoForms/InAppForms/IAFPresentationManager.swift — handler storage, currentFormContext, invocation
  • Sources/KlaviyoForms/InAppForms/IAFWebViewModel.swift — yields formId through lifecycle stream
  • Sources/KlaviyoForms/InAppForms/Models/IAFNativeBridgeEvent.swiftFormWillAppearPayload decodes formId; IAFLifecycleEvent.present carries formId
  • Examples/KlaviyoSwiftExamples/Shared/AppDelegate.swift — updated example

Test Plan

  • All new tests pass
  • All existing tests pass

Breaking Changes

None — purely additive.


🤖 Generated with Claude Code

@ajaysubra ajaysubra marked this pull request as ready for review February 23, 2026 19:57
@ajaysubra ajaysubra requested a review from a team as a code owner February 23, 2026 19:57
@klaviyoit klaviyoit requested a review from ab1470 February 23, 2026 19:57
ajaysubra added a commit that referenced this pull request Feb 24, 2026
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>
ajaysubra and others added 6 commits February 24, 2026 22:41
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>
@ajaysubra ajaysubra force-pushed the feat/form-lifecycle-hooks branch from 91009d9 to 714851c Compare February 25, 2026 04:44
.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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// STEP2C

does this correspond to something, like a reference in the readme? If not, can you remove it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Contributor

@belleklaviyo belleklaviyo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@ajaysubra
Copy link
Contributor Author

@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.

@ajaysubra
Copy link
Contributor Author

looks good, and thanks for adding good tests for this

All praise to the frenchman!

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

startProfileObservation()
case .present:
case let .present(formId):
currentFormContext = FormContext(formId: formId)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

ajaysubra and others added 2 commits March 5, 2026 21:58
@ajaysubra ajaysubra merged commit 44b5372 into master Mar 6, 2026
16 checks passed
@ajaysubra ajaysubra deleted the feat/form-lifecycle-hooks branch March 6, 2026 22:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants