Skip to content

fix(iaf): use bridge-provided context instead of persisted state#539

Draft
ajaysubra wants to merge 19 commits intomasterfrom
feat/form-lifecycle-hooks
Draft

fix(iaf): use bridge-provided context instead of persisted state#539
ajaysubra wants to merge 19 commits intomasterfrom
feat/form-lifecycle-hooks

Conversation

@ajaysubra
Copy link
Copy Markdown
Contributor

Summary

  • Removes currentFormContext stored property from IAFPresentationManager — no local form identity state is persisted between bridge events
  • formDisappeared and openDeepLink bridge events now decode and carry their own formId/formName payloads (previously formDisappeared discarded them entirely)
  • Context flows directly through each event's call chain rather than relying on state set by a prior formWillAppear event
  • Matches the Android SDK pattern (klaviyo-android-sdk#414)

Root cause this fixes: All lifecycle callbacks (form_shown, form_dismissed, form_cta_clicked) were showing the form version's internal name ("Default Popup") instead of the marketer-visible form name, because currentFormContext was set once by formWillAppear (which read from the wrong source) and reused for all subsequent events.

Changes

File Change
IAFNativeBridgeEvent formDisappeared and openDeepLink cases now carry formId/formName; added FormDisappearedPayload; updated DeepLinkEventPayload
IAFLifecycleEvent dismiss case now carries formId/formName
IAFWebViewModel Passes bridge payload through to lifecycle stream for both events
IAFPresentationManager Removed currentFormContext; presentForm, dismissForm, and invokeLifecycleHandler all take an explicit FormContext parameter
Tests Updated to reflect new call signatures and pattern matches

Test plan

  • Verify form_shown, form_dismissed, and form_cta_clicked all emit the correct marketer-visible form name (not "Default Popup")
  • Verify existing unit tests pass on CI

Part of klaviyo/fender#56023

🤖 Generated with Claude Code

ajaysubra and others added 13 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>
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>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove currentFormContext stored property from IAFPresentationManager.
Each bridge event (formWillAppear, formDisappeared, openDeepLink) now
carries its own formId/formName and threads it directly through the call
chain, matching the Android SDK pattern. No local state is persisted
between events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merge master into feat/form-lifecycle-hooks. Adopts master's
FormLifecycleEvent associated-value pattern (formId/formName/buttonLabel
on each case) while keeping the branch's no-stored-state approach for
context threading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

/// The display name of the form.
public let formName: String?
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FormContext struct is unused dead code

Low Severity

FormContext is a new public struct that isn't referenced anywhere in production code. The PR description states that methods take "an explicit FormContext parameter," but presentForm, dismissForm, and invokeLifecycleHandler all still use individual formId: String? and formName: String? parameters. This appears to be leftover from an incomplete refactor.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this is still around as dead code, then yes we should remove it

evan-masseau and others added 5 commits April 6, 2026 22:19
- Make formId, formName, and buttonLabel non-optional (String instead
  of String?) in FormLifecycleEvent, using empty string fallback when
  bridge data is missing
- Move formShown/formDismissed callback invocation from
  IAFPresentationManager to bridge layer (IAFWebViewModel), matching
  formCtaClicked pattern
- Remove dismiss safety net (hasInvokedDismissed guard and
  destroyWebView fallback) since dismiss is now purely bridge-driven
- Remove in-memory form state (lastPresentedFormId/lastPresentedFormName)
  and associated fallback logic from IAFPresentationManager
- Update tests and docstring examples to reflect non-optional types

Part of MAGE-287, aligns with Android SDK PR #414

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The formShown and formDismissed doc comments claimed behavior that no
longer matches the implementation. formShown now accurately describes
that it reflects the JS-side formWillAppear signal rather than firing
after native validation. formDismissed clarifies it does not fire for
pre-show scenarios like session timeouts or aborts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FormCtaClicked.deepLinkUrl is now URL (non-optional). If the bridge
message has no deep link URL configured, the CTA lifecycle event is
not emitted — there's nothing actionable for the host app.

Part of MAGE-287

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Align form lifecycle events with Android SDK
manager.invokeLifecycleHandler(for: .formCtaClicked(
formId: effectiveFormId,
formName: effectiveFormName,
buttonLabel: buttonLabel,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CTA lifecycle event lost when no deep link URL

High Severity

The formCtaClicked lifecycle event is no longer emitted when a CTA button has no deep link URL configured. The previous code explicitly fired this event unconditionally ("always fire, even if URL is nil/invalid") before the URL guard. Now the guard let url early-return on line 284 prevents invokeLifecycleHandler from ever being reached for URL-less CTAs. SDK consumers tracking all CTA clicks via registerFormLifecycleHandler will silently lose visibility into these interactions.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e90dbb9. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Intentional. If there's truly no iOS link, we don't hand the CTA click over to the host app for deep link handling either, so just matching that behavior.

// Fall back to the context captured at present time if the bridge sends nil identifiers
// (e.g. fender rollback or companion PR not yet deployed)
let effectiveFormId = formId ?? currentFormId
let effectiveFormName = formName ?? currentFormName
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Programmatic dismissals silently lose formDismissed lifecycle event

Medium Severity

destroyWebView previously checked whether a form was currently visible (presentingViewController != nil) and fired formDismissed as a safety net for timeout-based and programmatic dismissals. That safety net is removed. Now formDismissed is only emitted when the JS bridge sends formDisappeared, but destroyWebView nils the viewModel and viewController before the bridge can respond, so the event is silently lost. This affects session-timeout and API-key-change paths where a form is actively displayed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e90dbb9. Configure here.

Copy link
Copy Markdown
Contributor

@evan-masseau evan-masseau Apr 7, 2026

Choose a reason for hiding this comment

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

Intentional: the circumstances when iOS programmatically kills the webview preclude any actual form having been shown to the user, e.g. abort bc of network timeout.

Aligns eventName property with Swift enum case naming convention
and cross-platform consistency (RN already uses camelCase).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@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 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 053536d. Configure here.

case .formCtaClicked: return "form_cta_clicked"
case .formShown: return "formShown"
case .formDismissed: return "formDismissed"
case .formCtaClicked: return "formCtaClicked"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Public eventName format silently changed from snake_case

Medium Severity

The public eventName property values changed from "form_shown", "form_dismissed", and "form_cta_clicked" to "formShown", "formDismissed", and "formCtaClicked". Since this is a public property on a public enum, any SDK consumer comparing or logging these string values (e.g., forwarding to third-party analytics) would silently get different values without any compile-time warning.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 053536d. Configure here.

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.

2 participants