Skip to content

Version 5.3.0#547

Draft
evan-masseau wants to merge 10 commits intomasterfrom
rel/5.3.0
Draft

Version 5.3.0#547
evan-masseau wants to merge 10 commits intomasterfrom
rel/5.3.0

Conversation

@evan-masseau
Copy link
Copy Markdown
Contributor

@evan-masseau evan-masseau commented Apr 10, 2026

Description

Bump SDK version from 5.2.3 to 5.3.0 for the upcoming release.

Due Diligence

  • I am confident these changes are compatible with all iOS and XCode versions the SDK currently supports.

Release/Versioning Considerations

  • Patch Contains internal changes or backwards-compatible bug fixes.
  • Minor Contains changes to the public API.
  • Major Contains breaking changes.
  • Contains readme or migration guide changes.
  • This is planned work for an upcoming release.

Changelog / Code Overview

Version bump from 5.2.2 → 5.3.0 across:

  • Version.swift
  • All podspecs (KlaviyoCore, KlaviyoSwift, KlaviyoForms, KlaviyoSwiftExtension, KlaviyoLocation)
  • Test snapshots and hardcoded version strings
  • Example app Podfiles and project files

Test Plan

  • CI build and test suite should pass with updated version strings
  • Snapshot tests should match updated version

Related Issues/Tickets

@evan-masseau
Copy link
Copy Markdown
Contributor Author

bugbot run

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 0d378e7. Configure here.

@evan-masseau evan-masseau changed the title chore: bump SDK version to 5.3.0 [WIP] Version 5.3.0 Apr 10, 2026
evan-masseau and others added 2 commits April 16, 2026 15:37
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat(forms): add form lifecycle event hooks (#539)

Add registerFormLifecycleHandler public API on KlaviyoSDK exposing a
FormLifecycleEvent enum (formShown, formDismissed, formCtaClicked) so
host apps can observe in-app form lifecycle, matching the Android SDK.

Event identity flows directly from bridge messages, fixing a stale-name
bug where the form version's internal name was reported instead of the
marketer-visible name. Includes bridge-message field validation
(MAGE-484).
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 strings silently changed to camelCase

Low Severity

The public eventName property values changed from snake_case (form_shown, form_dismissed, form_cta_clicked) to camelCase (formShown, formDismissed, formCtaClicked). Unlike the String?String type changes that produce compile-time errors, this is a silent runtime behavior change. Any existing code comparing eventName against the old string values (e.g., for analytics routing or dashboard tracking) will silently stop matching without any compiler warning.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 082f13c. Configure here.

* Implement dynamic push action buttons (OneSignal-style)

This commit implements a hybrid approach for push notification action buttons,
supporting both dynamic (unlimited customization) and predefined (fallback) categories.

## What's New

### Dynamic Action Buttons (Primary)
- **Fully customizable button labels** per notification (e.g., "Shop Now", "Add to Cart")
- **No manual registration required** - categories registered automatically in NSE
- **Per-button deep links** - each button can have its own destination URL
- **iOS 15+ icon support** - SF Symbols on buttons using NSInvocation reflection
- **Localization-ready** - button labels come from server payload
- **FIFO category pruning** - maintains 128 category limit automatically

### How It Works
1. Push arrives with `action_buttons` array in payload
2. NSE intercepts, parses buttons, generates unique category ID
3. Registers category dynamically before notification displays
4. User taps button → tracks `$opened_push_action` event with action_id

### Implementation Details

**New Files:**
- `Sources/KlaviyoSwiftExtension/KlaviyoCategoryController.swift`
  - Manages dynamic category lifecycle (registration, persistence, pruning)
  - Uses app group UserDefaults for persistence
  - FIFO pruning at 128 categories
  - Smart merge to avoid overwriting existing categories

- `Sources/KlaviyoSwiftExtension/KlaviyoActionButtonParser.swift`
  - Parses action button definitions from push payload
  - Creates UNNotificationAction instances
  - Handles button reversal (2-button iOS convention)
  - iOS 15+ icon support via NSInvocation reflection

- `PUSH_ACTION_BUTTONS_PAYLOAD_SPEC.md`
  - Comprehensive payload specification for backend team
  - Validation rules and examples
  - Migration guide from predefined to dynamic
  - Troubleshooting guide

**Modified Files:**
- `Sources/KlaviyoSwiftExtension/KlaviyoExtension.swift`
  - Added `handleActionButtons()` method
  - Integrates dynamic category registration into NSE flow
  - Respects developer-set categories (no overwriting)

- `Sources/KlaviyoSwift/Utilities/UNNotificationResponse+Klaviyo.swift`
  - Added `isActionButtonTap`, `actionButtonId`, `actionButtonURL`, `actionButtonLabel`
  - Supports both dynamic and predefined payload formats
  - Extracts action-specific deep links and labels for analytics

- `Sources/KlaviyoSwift/Klaviyo.swift`
  - Updated notification response handler to detect action button taps
  - Added `handleActionButtonTap()` method
  - Tracks `$opened_push_action` events with action metadata

- `Sources/KlaviyoSwift/Models/Event.swift`
  - Added `_openedPushAction` event type
  - Maps to `$opened_push_action` in Klaviyo

### Payload Format

**Dynamic Buttons (Recommended):**
```json
{
  "aps": {
    "alert": "Flash Sale - 50% Off!",
    "mutable-content": 1
  },
  "body": {
    "_k": "unique_notification_id",
    "url": "myapp://home",
    "action_buttons": [
      {
        "id": "com.klaviyo.action.shop",
        "label": "Shop Now",
        "url": "myapp://sale/flash",
        "icon": "cart.fill"
      },
      {
        "id": "com.klaviyo.action.later",
        "label": "Remind Later",
        "url": "myapp://reminders"
      }
    ]
  }
}
```

**Predefined Categories (Fallback):**
- Existing predefined categories remain unchanged
- No breaking changes to current implementations

### Event Tracking

**Action Button Tap:**
- Event: `$opened_push_action`
- Properties: `action_id`, `action_label` (dynamic only), all notification properties
- Allows filtering by button type and A/B testing button labels

### Benefits Over Predefined Approach

✅ **Unlimited customization** - Any button labels, not limited to 4 combinations
✅ **Better for ecommerce** - Brand-appropriate labels ("Shop Now" vs generic "View")
✅ **Localization** - Server sends translated labels per user
✅ **A/B testing** - Test different button copy easily
✅ **Better UX** - No registration call needed from app developers
✅ **Production-proven** - OneSignal uses this approach at massive scale

### Backwards Compatibility

- Predefined categories still work unchanged
- Apps can use both approaches simultaneously
- Dynamic is primary, predefined is fallback (when NSE unavailable)

### Technical Approach

Based on research of OneSignal's iOS SDK implementation:
- Uses per-notification unique categories (not predefined sets)
- Registers categories in NSE (not at app launch)
- FIFO pruning prevents category bloat
- Smart merging respects developer's custom categories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix compilation errors in action button implementation

- Simplified icon creation code using direct method invocation instead of NSInvocation
- Fixed optional binding error in KlaviyoExtension (categoryIdentifier is non-optional)
- Build now succeeds without errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add comprehensive test payloads for dynamic push action buttons

Includes 10 ready-to-use test payloads covering:
- Basic 2-button layouts
- SF Symbols icons (iOS 15+)
- 3-button configurations
- Single button CTAs
- E-commerce scenarios (cart, back-in-stock, etc.)
- Predefined category fallback
- Hybrid approach (both formats)
- Localization examples
- Error case handling

Also includes:
- Quick test script for apns-cli
- Verification checklist
- SF Symbols reference for e-commerce
- Troubleshooting guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add JSON test payload files for apns-cli

Created 10 ready-to-use JSON payload files in test-payloads/ directory:
- 1-basic-two-buttons.json - Simple flash sale with 2 buttons
- 2-with-icons.json - Order delivered with SF Symbols icons
- 3-three-buttons.json - New arrivals with 3 buttons
- 4-single-button.json - Cart reminder with single CTA
- 5-abandoned-cart.json - Cart recovery scenario
- 6-back-in-stock.json - Product availability alert
- 7-predefined-fallback.json - Predefined category (no mutable-content)
- 8-hybrid.json - Both dynamic and predefined formats
- 9-localization-spanish.json - Spanish localization example
- 10-error-case.json - Invalid payload for error testing

Can be used directly with apns-cli:
  apns-cli send --payload test-payloads/1-basic-two-buttons.json ...

Includes README.md with quick start guide and test script.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add send-test.sh script for easy payload testing

* Remove predefined categories support (#503)
* Remove predefined category support and icon support, refactor test payloads

* run pre commit

* Delete spec.md file

* Remove separate opened push action event (#505)

* Add ActionType to button parser (#504)

* Remove predefined category support and icon support, refactor test payloads

* run pre commit

* Delete spec.md file

* Add ActionType to KlaviyoCore and expose to KlaviyoSwiftExtension

* Move ActionType to KlaviyoSwift

* Add ActionType helper

* Add ActionType to parser

* Prevent open_app buttons from deferring to overall deep link

* Fix logic on validation of openApp

* Add tests

* Move ActionType from KlaviyoSwift to KlaviyoCore

* Remove unreachable code

* Move validation to helper function, add test

* Make logic more concise

* Move parser tests to separate KlaviyoSwiftExtensionTests target, remove PoC test assets

* Update event schema for push action buttons (#506)

* Update event schema with Action button metadata

* Add tests

* Fix casing

* Remove TEST_PAYLOADS.md

* Add Button ID to opened push event

* Simplify button category registration (#508)

* Refactor isKlaviyoNotification helper to use it in multiple dicts

* Use proper klaviyo identifier

* Simplify category registration to hardcoded value

* Fix tests

* nit: fix file headers

* Safely register different categoryIdentifiers, ensure thread safety

* Use new registerCategory with com.klaviyo.buttom with identifier format

* Add KlaviyoSwiftExtension LoggeR (#510)

* Add push action button to README (#512)

* Add README section

* Add sample payload and TOC

* Add placeholder link

* Fix presumed release number, fix copy

* Clean up old UNNotifcationCategories (#511)

* Move KlaviyoCategoryController to KlaviyoCore

* Add pruneCategory method

* Prune category on opened push event

* Prune category if push was dismissed from Notification Center

* put prefix on KlaviyoCategoryController

* Add pruning against all displayed notifs

* Return true

* Rename KlaviyoCategoryController to KlaviyoCategoryManager

* Condense into a defer

* fix: fix test setup and refactor button parser (#555)

* fix: route pruneCategory through injectable environment to fix CI test crashes

On Xcode 16.4, UNUserNotificationCenter.current() throws
`bundleProxyForCurrentProcess is nil` when called from the test runner
(no host app). The defer block in handle(notificationResponse:) calls
KlaviyoCategoryManager.shared.pruneCategory() which hits this path,
crashing 6 tests on CI.

Fix by adding a `pruneCategory` closure to KlaviyoSwiftEnvironment
(matching the existing `setBadgeCount` pattern) and stubbing it to a
no-op in tests.

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

* refactor: deduplicate action button lookup and validate deep link URLs

Address cursorbot feedback on PR #502:

1. Extract matchingActionButton helper in UNNotificationResponse+Klaviyo
   to eliminate triplicated payload traversal across actionButtonURL,
   actionButtonLabel, and actionButtonType.

2. Add URL(string:) validation in isValidActionURLCombination so
   deep_link buttons with malformed URLs are rejected at parse time
   rather than silently failing on tap.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: consolidate isKlaviyoNotification onto notification types (#556)

Move isKlaviyoNotification off the raw userInfo dictionary and onto
UNNotificationContent (KlaviyoCore) and UNNotification (KlaviyoSwift),
so callers work with the most natural object rather than reaching deep
into the object graph.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Ajay Subramanya <ajay.subramanya@klaviyo.com>
Co-authored-by: Claude <noreply@anthropic.com>
Comment thread Sources/KlaviyoSwift/Klaviyo.swift
Comment thread Sources/KlaviyoSwift/Klaviyo.swift
* Add FormLayout structs

* Add presentation style to InAppFormsConfig

* Add InAppWindowManager

* Remove misreturn, make InAppWindowManager a singleton

* Add presentWithLayout event

* Create helper to determine if floating form window is active

* Implement branching logic for modal vs window presentation in IAFPresentationManager

* Mock custom layout coming from js

* Remove unused property

* Prevent mislayout during orientation changes

* Clean up guards

* Reorganize code

* Unify legacy present with presentWithLayout action, clean up mock, fix test

* Address cursor comments

* Parse actual layout data coming from js

* Fix formWillAppear test structure and uncomment log

* Address bugbot comments

* Bump formWillAppear to v2

* Tidy up Margins

* Fix centering for top and bottom (cursor comment)

* Respond to open comments, tidy up docs

* Remove redundant effectiveWidth/effectiveHeight

* fix(forms): assign currentFormId/Name in flexible form path and update handshake test

Flexible form presentation was not setting currentFormId/currentFormName,
causing dismissForm/destroyWebView to lose form identity on dismiss if the
bridge sends nil identifiers. Mirrors the existing modal path behaviour.

Also updates the handshake snapshot test to expect formWillAppear v2,
matching the version bump already in IAFNativeBridgeEvent.swift.

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

* MAGE-323: safe zone handling (#541)

* MAGE-323: safe zone handling

* use max rather than sum for user offset and safezone

* offset is user + safezone

* add safezone consideration for left and right sides

* fix: floor available dimensions at zero to prevent negative CGRect

- availableWidth and availableHeight can go negative when margins plus
  safe area insets exceed screen bounds (e.g. small devices in landscape)
- Wrap both in max(0, ...) so clampedWidth/clampedHeight stay non-negative

-Claude

* Handle keyboard and orientation changes smoother (#540)

* Better keyboard handling

* fix: address Cursor BugBot review feedback in InAppWindowManager

- Remove unused `viewController` weak property (dead store)
- Guard against keyboard adjustment for fullscreen forms to prevent off-screen shift

-Claude

* fix(forms): clamp keyboard avoidance shift to safe area top

Tall bottom-anchored forms could be pushed off-screen when the keyboard
appeared because the upward shift was unbounded. Clamp the adjusted
origin.y to the safe area top so the form stays visible.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(forms): decode formWillAppear payload at bridge layer instead of handler

Move formWillAppear from opaque Data to typed args (formId, formName,
layout), matching formDisappeared and openDeepLink. This decodes the
payload in IAFNativeBridgeEvent.init(from:) rather than deferring to
handleNativeBridgeEvent, removing the do/catch and simplifying the
handler. Add Equatable conformance to FormLayout types so the enum
can synthesize Equatable.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Amber Wallace <amber.wallace@klaviyo.com>
)

Update snapshot JSON files and add wildcard parameter to formWillAppear
pattern matches in IAFNativeBridgeEventTests to align with updated enum.

Co-authored-by: Claude Sonnet 4.6 <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 2 potential issues.

There are 4 total unresolved issues (including 2 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 c3a7a52. Configure here.

Comment thread KlaviyoSwiftExtension.podspec
Comment thread Sources/KlaviyoSwift/Klaviyo.swift
belleklaviyo and others added 4 commits April 20, 2026 14:26
* refactor(forms): simplify keyboard handling in InAppWindowManager

Move keyboard observers to singleton init so they persist across form
presentations. Track the latest keyboard frame in currentKeyboardFrame
and centralize avoidance logic in updateWindowFrame, replacing the
per-presentation setupObservers/removeObserver pattern. Also adds
keyboardWillChangeFrame observation, uses keyboardFrame.origin.y for
accurate keyboard top calculation, and removes makeKeyAndVisible.

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

* fix(forms): initialize InAppWindowManager earlier for keyboard observers

Access InAppWindowManager.shared during SDK registration so keyboard
notification observers are set up before any form is presented. Without
this, a form shown while the keyboard is already visible would not
receive prior keyboardWillShow events and would render without
accounting for the keyboard.

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

* rename keyboardEvent

Co-authored-by: Andrew Balmer <ab1470@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Andrew Balmer <ab1470@users.noreply.github.com>
…560)

* feat(forms): expose device info to onsite JS via data-klaviyo-device

Adds a `data-klaviyo-device` attribute on the in-app forms template head
that publishes screen dimensions, safe-area insets, orientation, and
device pixel ratio using CSSOM conventions. This lets onsite-in-app
compute flexible-form dimensions at HTML parse time without querying
potentially-stale `window.screen.*` before view-hierarchy attachment.

The attribute is injected via a `.atDocumentStart` `WKUserScript` so it
is set before any inline `<script>` in the template runs, and it is
re-published on orientation transitions (`viewWillTransition`) and
safe-area inset changes (`viewSafeAreaInsetsDidChange`) to keep onsite
in sync with the device state.

* fix(forms): harden device-info push and add test coverage

- Guard `viewSafeAreaInsetsDidChange` and the `viewWillTransition`
  coordinator completion on `view.window != nil && !webView.isLoading`
  so preload-phase changes don't trigger noisy `evaluateJavaScript`
  failures.
- Extract `document.head.setAttribute('data-klaviyo-device', ...)` into
  `DeviceInfo.asAttributeAssignmentScript()` so the injection-time and
  runtime-push call sites share one JS-escaping contract.
- Log `toJsonString()` encode failures at `.error` instead of silently
  falling back to `"{}"`.
- Add test coverage for the portrait-bounds landscape-swap inverse,
  `dpr` clamp at zero native scale, and stable JSON key ordering.
- Note wire-contract justification on the `top`/`dpr` identifier_name
  SwiftLint suppressions.

* feat(forms): rename layout offsets key and add addSafeAreaInsetsToOffsets flag

Updates the formWillAppear layout payload contract to match the new onsite
naming. Reads `offsets` first and transparently falls back to the legacy
`margin` key, logging a one-shot deprecation notice on the first fallback hit
per session.

Adds `addSafeAreaInsetsToOffsets` (default `true`) to the layout payload.
When `false`, the SDK positions the form using the provided offsets as-is
without adding the window's safe-area insets — lets onsite own safe-area
handling end-to-end when it has already baked the insets into its offsets.

FULLSCREEN continues to fill `screenBounds` and ignores offsets and the flag.

* refactor(forms): rename Margins struct to Offsets

Drop the Margins typealias-based compat shim in favor of a direct
rename now that all call sites use the Offsets name via the typealias.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(forms): clarify deviceInfoWKScript staleness window

* Fix this compile error

* fix(forms): use windowScene key window bounds; drop lock around deprecation flag

* refactor(forms): rename marginX locals to offsetX for naming consistency

Local variables in calculateFrame and its test helper still used the old
margin terminology. Renamed to offset to match the wire key and struct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(forms): drop dimension-swap heuristic now that window.bounds is source of truth

* refactor(forms): drop JS escape layer; inject device info via JSON.stringify

Instead of embedding the JSON payload inside a single-quoted JS string
literal and escaping single quotes / backslashes, embed the raw JSON as
a JS object expression and wrap with JSON.stringify at runtime. JSON is
a subset of JS, so the engine parses the object literal natively and
then stringifies it back for the attribute value.

Removes the klaviyoJsSingleQuoteEscaped extension and its escape-layer
tests — replaced by a single script-shape assertion. The WKUserScript
initial-injection path uses the same asAttributeAssignmentScript helper
and benefits automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(forms): flip landscape CSSOM mapping to match WebKit Safari

UIInterfaceOrientation.landscapeRight (home button on left, angle 90°)
should map to landscape-primary, and .landscapeLeft (home button on
right, angle 270°) to landscape-secondary. This matches how iOS Safari
itself populates screen.orientation.type.

Verified empirically on device — the previous mapping was inverted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(forms): don't read device bounds/insets from our own overlay window

The form's overlay UIWindow (created by InAppWindowManager at
windowLevel .normal + 1) becomes key whenever the user interacts with a
form input. At that point, DeviceInfo.current() was picking up our own
overlay's bounds / insets and publishing them as the 'screen', shrinking
reported dimensions to the flyout's frame.

- Dimensions now come from scene.coordinateSpace.bounds (the scene's
  full drawable rect), which is unaffected by which window is key.
- Safe-area insets come from the host app's window — our overlay is
  explicitly excluded via !== match on InAppWindowManager.currentFormWindow.
- nativeScale moves to scene.screen (window-agnostic).

Also drops the legacy 'margin' wire-key fallback now that onsite is
fully migrated to 'offsets' in production. Removes the now-dead
FormLayoutDeprecationLogger + resetForTesting hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KlaviyoSwiftExtension now depends on KlaviyoCore, so the CocoaPods
publish workflow needs --include-podspecs='*.podspec' to resolve
the dependency from local files during validation (before anything
is published to the CDN).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@belleklaviyo belleklaviyo marked this pull request as ready for review April 24, 2026 18:23
@belleklaviyo belleklaviyo requested review from a team as code owners April 24, 2026 18:23
@belleklaviyo belleklaviyo changed the title [WIP] Version 5.3.0 Version 5.3.0 Apr 24, 2026
@belleklaviyo belleklaviyo marked this pull request as draft April 24, 2026 18:30
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.

5 participants