diff --git a/documentation/authoring/README.md b/documentation/authoring/README.md index da7a7462..ee783218 100644 --- a/documentation/authoring/README.md +++ b/documentation/authoring/README.md @@ -220,10 +220,14 @@ blueprint-to-guide section and category agreement. `pnpm knowledge:check` valida integrity and unresolved escalations. Neither command proves behavioral truth; semantic freshness comes from the scoped knowledge-author and technical-foundation reviews. -Blueprints and KB files currently cover the six JS/TS integration guides: Node, Web, React Web, -Next.js App Router, Next.js Pages Router, and React Native. Native Swift/Kotlin guides, the decision -guide, and supplemental guides remain outside the regeneration guarantee until their KB and planning -edges are bootstrapped. +Blueprints and KB files currently cover the six JS/TS integration guides — Node, Web, React Web, +Next.js App Router, Next.js Pages Router, and React Native — plus the two native iOS guides (SwiftUI +and UIKit), which share one KB file (`native/ios.md`) and one per-UI blueprint each. Because the iOS +package is a Swift Package rather than a TypeScript one, `knowledge:check` cannot resolve Swift +`#symbol` pointers; iOS Swift facts use `extern:` pointers naming the exact file and symbol, while +behavior that executes in the shared JS bridge (`optimization-js-bridge`) or core uses resolvable +keys. The Android Kotlin guides, the decision guide, and supplemental guides remain outside the +regeneration guarantee until their KB and planning edges are bootstrapped. ## Files @@ -240,6 +244,8 @@ blueprints/ nextjs-app-router.md nextjs-pages-router.md react-native.md + ios-swiftui.md + ios-uikit.md fragments/ personalization-explainer.md authored-variant-gotcha.md diff --git a/documentation/authoring/blueprints/ios-swiftui.md b/documentation/authoring/blueprints/ios-swiftui.md new file mode 100644 index 00000000..f74a412f --- /dev/null +++ b/documentation/authoring/blueprints/ios-swiftui.md @@ -0,0 +1,86 @@ +--- +sdk: ios-swiftui +archetype: integration +kb: ../../internal/sdk-knowledge/native/ios.md +guide: ../../guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md +--- + +# iOS SwiftUI guide blueprint + +## Quick-start contract + +- **Outcome:** The SDK initializes inside a SwiftUI app and one accepted screen event is emitted. +- **Verification:** With `logLevel: .debug`, launch on a simulator and observe the accepted screen + event in the Xcode console; the Custom events section later adds a programmatic `eventStream` + observer. +- **Reader shape:** A SwiftUI `App` whose root wraps one or more screens. +- **Required artifacts:** Swift Package add and an Xcode build/run step; `OptimizationRoot` diff + around the app root; one screen with `.trackScreen(name:)`; a `logLevel` diagnostic; verification. +- **Deliberate simplifications:** Consent starts granted; entry rendering is deferred to Core because + the app must supply the Contentful fetch. +- **Explainer switches:** profile point = include; managed-fetch clause = omit. +- **Fact sources:** [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), + [events](../../internal/sdk-knowledge/native/ios.md#events--tracking), + [package](../../internal/sdk-knowledge/native/ios.md#package--entry-points). + +## Milestone contract + +- **Milestone 1:** The SDK initialized and reporting one screen event (the quick start), plus entries + resolving through `OptimizedEntry` once the app passes fetched Contentful entries (the Core entry + sections). +- **Milestone 2:** Policy-dependent and optional layers — consent handoff, interaction tracking, + identity, Custom Flags, live updates, preview, strict event policy, and offline delivery. +- **Boundary:** Mandatory provider wiring plus the first accepted event and entry render versus + opt-in application policy and capabilities. +- **Fact sources:** [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks), + [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution). + +## Section map + +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------ | ------------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Install and initialize the SwiftUI root | Required for first integration | Establish the SDK-owned client and the SwiftUI provider tree. | SPM add and Xcode build/run; `OptimizationRoot` diff; `@EnvironmentObject` access; readiness gate; synchronous throwing init. | [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), [package](../../internal/sdk-knowledge/native/ios.md#package--entry-points) | +| Consent and privacy-policy handoff | Common but policy-dependent | Replace the quick-start default-on shortcut and explain the two axes. | Two-axis example; boolean versus split consent; pre-consent allow-list; app-owned policy. | [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence) | +| Contentful entry fetching and locale shape | Required for first integration | Establish the app-owned single-locale fetch that feeds resolution. | App owns the CDA fetch (no managed path); single-locale and include depth; SDK locale versus CDA locale. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [identifiers](../../internal/sdk-knowledge/native/ios.md#identifier-ownership) | +| Entry resolution and fallback rendering | Required for first integration | Complete the entry half of Milestone 1 through `OptimizedEntry`. | Render-closure example and cast; direct `resolveOptimizedEntry` alternative; baseline fallback; loading treatment. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [fallback](../../internal/sdk-knowledge/native/ios.md#failure--fallback-behavior) | +| Screen events and SwiftUI navigation | Required for first integration | Deepen the quick-start screen proof across SwiftUI navigation. | `.trackScreen` placement; `trackCurrentScreen` for dynamic names and route keys; dedupe; choose-one-path warning. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Entry interaction tracking | Common but policy-dependent | Explain viewport-based views and taps and their opt-outs. | Scroll provider; thresholds; tap versus SwiftUI `Button`; `trackViews`/`trackTaps` opt-outs; `onTap` gating. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Identity, profile state, and reset | Common but policy-dependent | Integrate login/logout and profile continuity. | Identify/reset example; `UserDefaults` continuity; reset preserves consent. | [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/native/ios.md#identifier-ownership) | +| Custom events and analytics diagnostics | Optional | Cover business events and the debug/forwarding seam. | `track` example; passthrough `eventStream` with subscribe-before-fire; `blockedEventStream`; forwarding link. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Custom Flags and MergeTag rendering | Optional | Cover content-model-dependent reads. | `getFlag` versus `flagPublisher`; `getMergeTagValue`; flag-view exposure governance. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking), [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution) | +| Live updates | Optional | Allow mounted entries to re-resolve. | Root and per-entry scopes; locked-by-default explanation; preview forces live; panel-close snapshot. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks) | +| Preview panel | Optional | Add the debug/authoring surface. | Debug gating; `PreviewPanelConfig`; `PreviewContentfulClient` for names; overlay placement. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks) | +| Strict event policy and endpoint controls | Advanced or production-only | Expose production privacy and endpoint posture. | `allowedEventTypes: []`; `onEventBlocked`/`blockedEventStream` proof; API endpoint overrides; queue policy. | [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence), [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory) | +| Offline delivery and lifecycle flushing | Advanced or production-only | Explain constrained-network delivery. | Reachability and background flush; in-memory queue plus 100-event cap; no durable outbox; `flush()` checkpoints. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks), [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | + +## SDK-specific authoring overrides + +- Omit the personalization-explainer managed-fetch clause: the iOS SDK has no fetch-by-ID path, so + the app always owns the Contentful fetch. Facts: + [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution). +- Prove "init plus one accepted screen event" rather than an entry render in the quick start: iOS has + no managed fetch, so an entry-render proof would force an app-specific Swift CDA fetch into the + quick start. Entry rendering moves to Core. Facts: + [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), + [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution). +- Keep the native package/build step inline in the quick start: add the Swift Package in Xcode, then + build and run on a simulator (there is no `pod install`). Facts: + [package](../../internal/sdk-knowledge/native/ios.md#package--entry-points). +- Use screen/tap/viewport vocabulary throughout; never import page/click/hover vocabulary from web. + Facts: [events](../../internal/sdk-knowledge/native/ios.md#events--tracking). + +## Troubleshooting scope + +| Reader symptom | Why it belongs | Fact sources | +| ------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Personalized content stays baseline | Common payload, consent, and locale symptoms for entry rendering. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence) | +| Entry view or tap events are missing | Makes viewport thresholds, scroll context, and consent actionable. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Screen events duplicate or go missing | Screen-modifier placement and route-key dedupe. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Preview panel shows identifiers only | Missing preview Contentful client. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks) | +| Flag values do not update | Subscription lifetime and profile-change dependency. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | + +## Link roles + +- [The UIKit iOS integration guide](../../guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md). +- [The analytics-forwarding supplemental guide](../../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). +- [The maintained iOS reference implementation](../../../implementations/ios-sdk/README.md). diff --git a/documentation/authoring/blueprints/ios-uikit.md b/documentation/authoring/blueprints/ios-uikit.md new file mode 100644 index 00000000..3fdc0d1d --- /dev/null +++ b/documentation/authoring/blueprints/ios-uikit.md @@ -0,0 +1,87 @@ +--- +sdk: ios-uikit +archetype: integration +kb: ../../internal/sdk-knowledge/native/ios.md +guide: ../../guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md +--- + +# iOS UIKit guide blueprint + +## Quick-start contract + +- **Outcome:** The SDK initializes in a UIKit scene and one accepted screen event is emitted, and a + visible label flips to an accepted state. +- **Verification:** The on-screen label reads `Optimization screen event accepted`; a `logLevel` or + `onEventBlocked` diagnostic explains a blocked outcome so the failure branch is not a dead end. +- **Reader shape:** A UIKit `SceneDelegate` and one `UIViewController`. +- **Required artifacts:** Swift Package add and an Xcode build/run step; a `SceneDelegate` diff that + owns one client and calls `initialize`; a view controller that tracks the current screen in + `viewDidAppear`; the visible-label verification; a failure diagnostic. +- **Deliberate simplifications:** Consent starts granted; entry rendering is deferred to Core because + the app must supply the Contentful fetch. +- **Explainer switches:** profile point = include; managed-fetch clause = omit. +- **Fact sources:** [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), + [events](../../internal/sdk-knowledge/native/ios.md#events--tracking). + +## Milestone contract + +- **Milestone 1:** The SDK initialized in the scene and one accepted screen event (the quick start), + plus entries resolving through `resolveOptimizedEntry` once the app passes fetched Contentful + entries (the Core entry section). +- **Milestone 2:** Policy-dependent and optional layers — consent handoff, interaction tracking, + identity, Custom Flags, live updates, preview, runtime locale changes, and offline delivery. +- **Boundary:** Mandatory scene wiring plus the first accepted event and entry render versus opt-in + application policy and capabilities. +- **Fact sources:** [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), + [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks). + +## Section map + +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Package installation and SDK configuration | Required for first integration | Establish the package and the one `OptimizationConfig`. | SPM add and Xcode build/run; required versus defaulted config keys; endpoint overrides only when non-default. | [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), [package](../../internal/sdk-knowledge/native/ios.md#package--entry-points) | +| Client lifetime and UIKit injection | Required for first integration | Own one client for the scene/app lifetime and inject it manually. | Create-and-initialize placement; manual injection through initializers; main-actor calling; readiness. | [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory) | +| Consent and privacy-policy handoff | Common but policy-dependent | Replace the quick-start default-on shortcut and explain the two axes. | Two-axis example; boolean versus split consent; pre-consent allow-list; app-owned policy. | [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence) | +| Contentful fetching and entry resolution | Required for first integration | Establish the app-owned fetch and the imperative resolver. | App owns the CDA fetch (no managed path); single-locale and include depth; synchronous fail-soft resolve; baseline fallback. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [fallback](../../internal/sdk-knowledge/native/ios.md#failure--fallback-behavior) | +| Screen and navigation tracking | Required for first integration | Deepen the quick-start screen proof across UIKit navigation. | `trackCurrentScreen` from `viewDidAppear`; route-key dedupe; `screen` versus `trackCurrentScreen` choice. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Entry interaction tracking | Common but policy-dependent | Explain UIKit-owned taps and view timing. | Tap payload from stored resolution; `ViewTrackingController` timing model; app owns geometry; opt-outs and duplicate prevention. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Identity, profile continuity, and reset | Common but policy-dependent | Integrate login/logout and profile continuity. | Identify/reset example; `UserDefaults` continuity; reset preserves consent. | [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/native/ios.md#identifier-ownership) | +| Custom events and analytics diagnostics | Optional | Cover business events and the debug/forwarding seam. | `track` example; passthrough `eventStream` with subscribe-before-fire; `blockedEventStream`; forwarding link. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Custom Flags and MergeTag rendering | Optional | Cover content-model-dependent reads. | `getFlag` versus `flagPublisher`; `getMergeTagValue`; flag-view exposure governance. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking), [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution) | +| Live updates and locked variants | Optional | Let UIKit apps choose live or locked content. | Locked snapshot versus live subscription; `nil` versus explicit selections; preview forces redraw. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks) | +| Preview panel | Optional | Add the UIKit debug/authoring surface. | Debug gating; `PreviewPanelViewController`; `PreviewContentfulClient` for names; same client instance. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks) | +| Runtime locale changes | Optional | Handle post-startup locale switches. | `setLocale` updates SDK locale only; refetch CDA and re-resolve; locale-aware cache keys. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks), [identifiers](../../internal/sdk-knowledge/native/ios.md#identifier-ownership) | +| Offline delivery, queue observability, and app-owned caching | Advanced or production-only | Expose production delivery posture and the app cache boundary. | Default offline path; queue policy and callbacks; in-memory 100-event cap; app-owned content caching. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks), [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | + +## SDK-specific authoring overrides + +- Omit the personalization-explainer managed-fetch clause: the iOS SDK has no fetch-by-ID path, so + the app always owns the Contentful fetch. Facts: + [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution). +- Prove "init plus one accepted screen event" in the quick start, keeping the existing visible-label + proof but adding a failure-branch diagnostic. Entry rendering moves to Core because iOS has no + managed fetch. Facts: [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory), + [events](../../internal/sdk-knowledge/native/ios.md#events--tracking). +- Show the `SceneDelegate` runtime root as a `+`/`-` diff, not a full paste-over: it is a file every + app already owns. Facts: [setup](../../internal/sdk-knowledge/native/ios.md#setup--factory). +- Split screen, entry-interaction, and custom-event tracking into distinct sections rather than one + multi-feature section, so each is its own reader goal with its own category. Facts: + [events](../../internal/sdk-knowledge/native/ios.md#events--tracking). +- Use screen/tap/viewport vocabulary throughout; never import page/click/hover vocabulary from web. + Facts: [events](../../internal/sdk-knowledge/native/ios.md#events--tracking). + +## Troubleshooting scope + +| Reader symptom | Why it belongs | Fact sources | +| -------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Optimized entries always render the baseline | Common payload and resolver symptoms for imperative resolution. | [render](../../internal/sdk-knowledge/native/ios.md#render--entry-resolution), [identifiers](../../internal/sdk-knowledge/native/ios.md#identifier-ownership) | +| Tap or view events do not appear | Makes consent, metadata, and UIKit visibility wiring actionable. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Screen events appear more than once | UIKit lifecycle repeats and route-key dedupe. | [events](../../internal/sdk-knowledge/native/ios.md#events--tracking) | +| Preview panel opens but shows identifiers | Missing preview Contentful client. | [runtime](../../internal/sdk-knowledge/native/ios.md#version--runtime-quirks) | +| Identified variants disappear after relaunch | Persistence consent and profile-continuity timing. | [consent](../../internal/sdk-knowledge/native/ios.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/native/ios.md#identifier-ownership) | + +## Link roles + +- [The SwiftUI iOS integration guide](../../guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md). +- [The analytics-forwarding supplemental guide](../../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). +- [The maintained iOS reference implementation](../../../implementations/ios-sdk/README.md). diff --git a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md index 08ff224e..c1cab19a 100644 --- a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md +++ b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md @@ -1,25 +1,73 @@ # Integrating the Optimization iOS SDK in a SwiftUI app -Use this guide when you want to add Optimization, Analytics, screen tracking, entry interaction -tracking, Custom Flags, and preview overrides to a SwiftUI application using the Optimization iOS -SDK. - -The SwiftUI integration uses `OptimizationRoot`, `OptimizedEntry`, `OptimizationScrollView`, and -`.trackScreen(name:)`. Your application still owns Contentful entry fetching, consent policy, -identity policy, navigation, and final rendering. Use the UIKit guide instead when your app is -UIKit-based: -[Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md). +Use this guide to add Contentful personalization to a SwiftUI app using the Optimization iOS SDK. By +the end of the quick start, the SDK is initialized inside your SwiftUI app and emits one screen event +that its consent gate accepts — the event Contentful uses to keep that visitor's personalization +consistent. + +**New to personalization?** Here is the whole idea in five points: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- As the app runs, Contentful's **Experience API** looks at who the visitor is and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- The Experience API also returns a **profile**: the anonymous, per-visitor identity and state used + to keep personalization consistent across requests or app launches. +- Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK + gives back the selected variant, or the original entry when no variant applies—the **baseline + fallback**. +- You render the returned entry with the same application components you already use. + +The iOS SDK persists the profile to `UserDefaults` across app launches when persistence consent +allows it. + +That is enough to start. The guide introduces policy and optional capabilities at the point you need +them. + +You will get there in two milestones: + +- **Milestone 1 — the SDK initialized, reporting one screen event, and resolving entries (the quick + start below plus the Core entry sections).** The quick start proves initialization and one accepted + screen event. The [Contentful entry fetching and locale shape](#contentful-entry-fetching-and-locale-shape) + and [Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) sections + then add entries resolving through `OptimizedEntry` once your app passes it fetched Contentful + entries. This is complete and shippable on its own. +- **Milestone 2 — the opt-in layers (later).** Consent handoff, interaction tracking, identity, + Custom Flags, live updates, the preview panel, strict event policy, and offline delivery, each + introduced by the section that needs it. + +This guide uses the `ContentfulOptimization` Swift Package. You mount one `OptimizationRoot` around +the SwiftUI tree that uses SDK views; it creates and initializes the SDK client, restores state from +`UserDefaults`, and provides it to the components and modifiers below it. Your app still owns its +Contentful entry fetching, consent policy, identity policy, navigation, and final rendering. If your +app is UIKit-based, use +[the UIKit iOS integration guide](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) instead. ## Quick start -This path assumes your application policy permits Optimization by default. If your app requires -explicit opt-in with no pre-consent SDK events, set `allowedEventTypes: []` before mounting this -path or defer `.trackScreen(name:)` until consent is accepted. - -1. Add the Swift Package through Swift Package Manager. In Xcode, add the package dependency with - `https://github.com/contentful/optimization.swift`, or add the package to `Package.swift`. +Most SwiftUI + Contentful apps share one shape: an `App` whose `WindowGroup` wraps a root view, with +screens fetched or built inside that tree. This quick start assumes that shape and proves the +smallest result: **the SDK initializes and emits one screen event that its consent gate accepts** — an +"accepted" event is one the SDK's local consent and allow-list checks let through to send, which is +what you can observe on the device; it is not a confirmation that Contentful received it. Entry +rendering needs an app-specific Contentful fetch, so it moves to +[Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) in Core; here you +wrap your app root in `OptimizationRoot` and mark one screen with `.trackScreen(name:)`. + +This quick start assumes your application policy permits Optimization to start with accepted consent +and renders no end-user consent UI, so it sets `defaults: StorageDefaults(consent: true)` — the +shorthand that accepts both consent axes at once. If personalization must wait for a consent +decision, keep this structure and add the +[Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step before you ship, which +explains the two axes and the split form that sets them separately. + +1. Add the `ContentfulOptimization` Swift Package. In Xcode, choose **File → Add Package + Dependencies** and enter the package URL `https://github.com/contentful/optimization.swift`. If + your app is defined by a `Package.swift` manifest, add the dependency and product there instead + and set a real version for `from:`. - **Copy this:** + **Adapt this to your use case:** ```swift dependencies: [ @@ -35,81 +83,68 @@ path or defer `.trackScreen(name:)` until consent is accepted. ] ``` -2. Configure the SDK, mount `OptimizationRoot`, attach screen tracking, and render one app-fetched - single-locale Contentful entry through `OptimizedEntry`. The screen event supplies visitor - context, but the quick-start proof is the rendered entry. + There is no `pod install` step for a Swift Package. After adding the package, build and run the + app on a simulator (**Product → Run**, or ⌘R) so the SDK's bundled JavaScript runtime resource is + linked into the build. + +2. Wrap your app root in `OptimizationRoot`, pass your Optimization client ID, set `logLevel: .debug` + so the SDK logs its activity, and add `.trackScreen(name:)` to one screen you already render. **Adapt this to your use case:** - ```swift - import ContentfulOptimization - import SwiftUI - - let appLocale = "en-US" - - let optimizationConfig = OptimizationConfig( - clientId: "", - environment: "main", - locale: appLocale, - // Use default accepted consent only when your application policy permits it. - defaults: StorageDefaults(consent: true) - ) - - @main - struct MyApp: App { - var body: some Scene { - WindowGroup { - // Mount one SDK-owned client around the SwiftUI tree that uses SDK views. - OptimizationRoot(config: optimizationConfig) { - HomeScreen() - } - } - } - } - - struct HomeScreen: View { - @State private var hero: [String: Any]? - - var body: some View { - Group { - if let hero { - // Renders the selected variant, or the baseline entry when no variant matches. - OptimizedEntry(entry: hero) { resolvedEntry in - Text(entryId(from: resolvedEntry)) - } - } else { - ProgressView() - } - } - // Required setup: attach screen tracking once to the stable screen root. - .trackScreen(name: "Home") - .task { - // Use your app-owned CDA client; fetch one locale and include optimization links. - hero = await fetchSingleLocaleHeroEntry(locale: appLocale) - } - } - } - - func entryId(from entry: [String: Any]) -> String { - let sys = entry["sys"] as? [String: Any] - return sys?["id"] as? String ?? "missing-entry-id" - } - - func fetchSingleLocaleHeroEntry(locale: String) async -> [String: Any]? { - // Replace with your app-owned Contentful Delivery API call. - // Include linked `nt_experiences`, `nt_config`, and `nt_variants` data. - nil - } + ```diff + import SwiftUI + +import ContentfulOptimization + + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + - HomeScreen() + + // Wrap the tree that uses SDK views; one client stays alive for its lifetime. + + OptimizationRoot( + + config: OptimizationConfig( + + clientId: "", + + // Accepted startup consent; the Consent section replaces this with your policy. + + defaults: StorageDefaults(consent: true), + + // .debug surfaces the accepted screen event in the Xcode console. + + logLevel: .debug + + ) + + ) { + + HomeScreen() + + } + } + } + } + + struct HomeScreen: View { + var body: some View { + HomeContent() + + // Emits one screen event on appear; the SDK dedupes repeats of the same screen. + + .trackScreen(name: "Home") + } + } ``` -3. Verify the first run. The entry ID text renders the baseline entry when no selected variant is - available, or the selected variant entry when the Experience API selects one for the visitor. + The `MyApp` and `HomeScreen` scaffolding above is illustrative context to match against your own + app, not a file to paste over yours. Wrap your existing app root in `OptimizationRoot` and add + `.trackScreen(name:)` to a screen you already render — keep the rest of your views as they are. + +3. Verify the first run. Launch the app on a simulator. Because `logLevel: .debug` is set, the SDK + logs its activity to the Xcode console under the `com.contentful.optimization` subsystem. The + `.trackScreen(name:)` modifier sends the screen event through `trackCurrentScreen`, so filter the + console for `optimization` and look for the pair of bridge lines it logs — + `[bridge] Calling trackCurrentScreen async` followed by `[bridge] trackCurrentScreen succeeded`, + whose result payload contains `"accepted":true`. That `succeeded` line with `accepted` true is the + proof the event passed the consent gate. The [Custom events and analytics diagnostics](#custom-events-and-analytics-diagnostics) + section adds a programmatic `eventStream` observer for asserting on events in code rather than + reading logs.
Table of Contents -- [Required setup](#required-setup) +- [Before you start](#before-you-start) - [Core integration](#core-integration) - [Install and initialize the SwiftUI root](#install-and-initialize-the-swiftui-root) - [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) @@ -133,33 +168,38 @@ path or defer `.trackScreen(name:)` until consent is accepted.
-## Required setup - -Use this table as the setup inventory for the guide: - -| Setup item | Category | Required for quick start | Where to configure | -| --------------------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------- | -| `ContentfulOptimization` Swift Package | Required for first integration | Yes | Swift Package Manager, Xcode package dependencies, or app `Package.swift` | -| iOS 15 or macOS 12 application target | Required for first integration | Yes | Xcode deployment target or Swift Package platform constraints | -| Optimization client ID and environment | Required for first integration | Yes | `OptimizationConfig`, usually from app configuration | -| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `OptimizationApiConfig` when using non-default production, staging, or mock endpoints | -| Contentful Delivery API client, space, environment, and access token | Required for first integration | Yes | Application-owned Contentful fetching layer | -| Contentful entries with linked optimization and variant data | Required for first integration | Yes | Contentful content model and entries rendered by the app | -| Single Contentful CDA locale and enough include depth for optimized entries | Required for first integration | Yes | App-owned CDA requests before passing entries to the SDK | -| `OptimizationRoot` mounted once around the SwiftUI tree that uses the SDK | Required for first integration | Yes | SwiftUI app root, scene root, or feature root | -| Screen event names for SwiftUI screens and navigation destinations | Required for first integration | Yes | `.trackScreen(name:)` or app-owned screen tracking calls | -| Consent startup policy and user-choice wiring | Common but policy-dependent | Conditional | `StorageDefaults`, `allowedEventTypes`, and application consent UI or CMP callbacks | -| Entry view and tap tracking policy | Common but policy-dependent | Conditional | `OptimizationRoot` tracking defaults and per-entry `OptimizedEntry` options | -| User identity, profile-continuity persistence, and reset policy | Common but policy-dependent | No | Account, session, or identity views that call `identify(...)` and `reset()` | -| Custom Flags and MergeTag rendering | Optional | No | Components that read SDK-resolved flags or rich-text MergeTag entries | -| Analytics forwarding or debug event display | Optional | No | `eventStream`, `blockedEventStream`, and application-owned analytics code | -| Preview panel and Contentful preview-definition client | Optional | No | `PreviewPanelConfig`, `PreviewPanelOverlay`, and debug or internal-build gates | -| Strict pre-consent allow-list, queue policy, and blocked-event diagnostics | Advanced or production-only | No | `OptimizationConfig` options and release configuration | -| Local native validation path | Advanced or production-only | No | iOS reference implementation scripts or XCUITest wrappers | - -The iOS SDK does not fetch Contentful entries for your application UI. Fetch entries in the -application layer, then pass the resulting single-locale dictionaries to `OptimizedEntry` or -`client.resolveOptimizedEntry(...)`. +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- **A native SwiftUI app you can build in Xcode**, with its own Contentful entry fetching already + working. The iOS SDK does not fetch Contentful entries for your application UI — you fetch them in + the app layer and pass the resulting single-locale dictionaries to `OptimizedEntry` or + `resolveOptimizedEntry(...)`. The SDK targets iOS 15+ / macOS 12+; it ships as a Swift Package with + no `pod install` step, so you add it in Xcode (or `Package.swift`) and run a normal build on a + simulator. +- **Contentful delivery credentials** — space ID, delivery token, and environment — read from your + app's runtime configuration and used by your own Contentful fetching layer. +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant, the integration can still run correctly while returning the baseline, so you + cannot yet distinguish working personalization from a content-authoring gap. For the first + personalized-content test, target all visitors so the test request or visitor matches automatically. +- **Your Optimization project values** — client ID and environment, from your Optimization project + settings. The Experience API (which picks variants) and the Insights API (which receives event and + interaction delivery) each have a base URL that defaults correctly; you set them only for mocks or + non-default hosts (see [Install and initialize the SwiftUI root](#install-and-initialize-the-swiftui-root)). + +You do not need a setup inventory up front. Everything else — consent, entry resolution, screen +tracking, interaction tracking, identity, live updates, preview, offline delivery — is introduced by +the section that needs it. + +> [!NOTE] +> +> Read the SDK and Contentful config from your app's runtime configuration. This guide's examples use +> inline placeholder strings for clarity; the reference implementation reads its values from shared +> app configuration because it runs against shared mock defaults. Use whatever configuration +> convention your iOS app already uses and keep it consistent. ## Core integration @@ -167,19 +207,28 @@ application layer, then pass the resulting single-locale dictionaries to `Optimi **Integration category:** Required for first integration -`OptimizationRoot` is the normal SwiftUI entry point. It creates and initializes an -`OptimizationClient`, injects it into the SwiftUI environment, provides tracking defaults to -descendant `OptimizedEntry` views, and renders a `ProgressView` until the client is ready. For -package status and installation options, see the -[Optimization iOS SDK README](../../packages/ios/ContentfulOptimization/README.md). - -1. Add `ContentfulOptimization` as a Swift Package dependency. -2. Create one `OptimizationConfig` with the Optimization client ID, environment, and SDK - Experience/event locale. -3. Pass endpoint overrides only when your app uses non-default Experience API or Insights API hosts. -4. Wrap the SwiftUI tree that uses SDK views and modifiers in `OptimizationRoot`. -5. Read the initialized client from `@EnvironmentObject` inside descendant views that call SDK - methods directly. +You wrapped your app root in `OptimizationRoot` in the quick start; this section covers its full +configuration surface. `OptimizationRoot` is the normal SwiftUI entry point: it owns one +`OptimizationClient` as a `@StateObject`, calls the client's `initialize(config:)` in a `.task`, +injects the client into the SwiftUI environment as an `@EnvironmentObject`, provides tracking +defaults to descendant `OptimizedEntry` views, and renders a `ProgressView()` until the client +reports `isInitialized`. Descendant views that call SDK methods directly read the client with +`@EnvironmentObject`. + +`initialize(config:)` is synchronous and `throws` — it loads the SDK's bundled JavaScript runtime and +runs bridge initialization inline on the main actor, so it briefly blocks the main actor at startup +rather than awaiting. `OptimizationClient` is a `@MainActor` type; call its methods from SwiftUI view +tasks, event handlers, or other main-actor contexts. + +1. Add `ContentfulOptimization` as a Swift Package dependency and build the app on a simulator. +2. Create one `OptimizationConfig` with the Optimization client ID. `environment` defaults to `main`, + so pass it only when your Contentful environment differs. +3. Pass `locale` when Experience API responses and event context must use the same app locale as your + Contentful entry fetches. +4. Pass `api` endpoint overrides only for staging, mocks, or non-default hosts; both base URLs default + correctly otherwise, so most apps omit `api`. +5. Read the initialized client from `@EnvironmentObject` inside descendant views that call SDK methods + directly. **Adapt this to your use case:** @@ -187,20 +236,18 @@ package status and installation options, see the import ContentfulOptimization import SwiftUI -let config = OptimizationConfig( - clientId: "", - environment: "main", - locale: "en-US", - logLevel: .error -) - @main struct MyApp: App { var body: some Scene { WindowGroup { - // Keep one SDK-owned client alive for the SwiftUI tree that uses Optimization. + // One SDK-owned client stays alive for the SwiftUI tree that uses Optimization. OptimizationRoot( - config: config + config: OptimizationConfig( + clientId: "", + // environment defaults to "main"; set it only when your Contentful environment differs. + locale: "en-US", + logLevel: .warn + ) ) { RootView() } @@ -209,53 +256,49 @@ struct MyApp: App { } struct PurchaseButton: View { - // Descendant views use the client created by OptimizationRoot. + // Descendant views read the client OptimizationRoot created and initialized. @EnvironmentObject private var client: OptimizationClient var body: some View { Button("Purchase") { Task { - _ = try? await client.track( - event: "Purchase Completed", - properties: ["sku": "sku-1"] - ) + // OptimizationClient is @MainActor; call it from tasks or event handlers. + _ = try? await client.track(event: "Purchase Completed", properties: ["sku": "sku-1"]) } } } } ``` -`OptimizationClient` is `@MainActor`. Call it from SwiftUI view tasks, event handlers, or explicit -main-actor tasks. For lifecycle details, see +`logLevel` defaults to `.error`; `.debug` and `.log` also enable remote JavaScript inspection in +debug builds. For lifecycle details, see [iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-main-actor). +For package status and installation options, see +[the Optimization iOS SDK README](../../packages/ios/ContentfulOptimization/README.md). ### Consent and privacy-policy handoff **Integration category:** Common but policy-dependent -Consent policy remains application-owned. Use the default accepted startup path only when -application policy permits Optimization by default and no end-user consent UI is rendered. -Otherwise, leave consent unset and connect your CMP, account preference, or in-app banner to the -SDK. - -1. Seed accepted consent with `StorageDefaults(consent: true)` when policy permits default-on - Optimization. -2. Leave consent unset when the app needs to collect a user decision first. -3. Call `client.consent(true)` after the visitor accepts and `client.consent(false)` after the - visitor rejects. -4. Use split consent when events are allowed but durable profile continuity must stay session-only. -5. Read `client.state.consent` and `client.state.persistenceConsent` when consent UI needs to - reflect SDK state. - -**Copy this:** - -```swift -let defaultOnConfig = OptimizationConfig( - clientId: "", - // Accepted startup consent enables events and durable profile continuity. - defaults: StorageDefaults(consent: true) -) -``` +Consent policy stays application-owned. Consent has two independent axes: **event consent** (may the +SDK personalize and emit events) and **persistence consent** (may the SDK store profile continuity in +`UserDefaults`). The boolean call `client.consent(_:)` sets both at once; the split call +`client.consent(events:persistence:)` sets them independently. `StorageDefaults(consent: true)` seeds +accepted event and persistence consent at startup — use it only when application policy permits +Optimization by default and you render no consent UI. + +`StorageDefaults` values are startup defaults, not one-time seeds: a configured value takes precedence +over the stored `UserDefaults` value every launch, so a configured `consent` can replace a stored +choice. Apps that persist a user's own decision leave `StorageDefaults.consent` unset and call +`client.consent(...)` from resolved app policy instead. + +1. Seed accepted consent with `StorageDefaults(consent: true)` only when policy permits default-on + Optimization and no consent UI is shown. +2. Otherwise leave consent unset and call `client.consent(true)` after the visitor accepts, + `client.consent(false)` after they reject. +3. Use the split form when events are allowed but durable profile continuity must stay session-only. +4. Read `client.state.consent` and `client.state.persistenceConsent` when consent UI must reflect SDK + state. **Adapt this to your use case:** @@ -266,7 +309,7 @@ struct ConsentBanner: View { var body: some View { HStack { Button("Accept") { - // Accepts event emission and durable profile-continuity persistence. + // Boolean consent accepts both event emission and durable profile continuity. client.consent(true) } Button("Reject") { @@ -276,24 +319,8 @@ struct ConsentBanner: View { } } } - -struct ConsentGate: View { - @EnvironmentObject private var client: OptimizationClient - @ViewBuilder var content: () -> Content - - var body: some View { - if client.state.consent == nil { - ConsentBanner() - } else { - content() - } - } -} ``` -Boolean consent controls both event emission and durable profile-continuity persistence by default. -When those policy decisions differ, call the split form: - **Copy this:** ```swift @@ -301,10 +328,13 @@ When those policy decisions differ, call the split form: client.consent(events: true, persistence: false) ``` -When `allowedEventTypes` is unset, the native default allow-list lets `identify` and `screen` emit -before consent, even after `client.consent(false)` blocks non-allowed events and clears persistence -and profile-continuity consent. Strict opt-in apps can set `allowedEventTypes: []` or a narrower -list when policy must block every SDK event or only permit specific events before consent. For the +Before event consent is accepted, the native default allow-list lets `identify` and `screen` events +emit; entry-view events (wire type `component`), tap events (`component_click`), and custom `track` +events are blocked until consent is accepted or you allow-list them. `client.consent(false)` +clears event and persistence consent, purges queued events, and clears durable profile continuity, +while in-memory state stays usable until reset or teardown. To block every SDK event before consent — +including `identify` and `screen` — set `allowedEventTypes: []`; see +[Strict event policy and endpoint controls](#strict-event-policy-and-endpoint-controls). For the cross-SDK consent model, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). @@ -312,42 +342,47 @@ cross-SDK consent model, see **Integration category:** Required for first integration -The SDK resolver expects standard single-locale CDA entry dictionaries. Your app must fetch -Contentful entries, resolve linked optimization references, and pass localized field values directly -to the SDK. Do not pass all-locale CDA responses such as `locale=*`. - -1. Choose the application Contentful locale in your app routing, i18n, account, or native state - layer. -2. Pass the same locale to `OptimizationConfig(locale:)` when Experience API responses and event - context must align with rendered Contentful content. -3. Fetch entries from Contentful with a concrete locale and enough include depth for - `nt_experiences`, `nt_config`, and `nt_variants`. -4. Keep cache keys locale-aware when localized entries can differ. -5. When the app locale changes, call `client.setLocale(...)`, refetch Contentful entries with the - app locale, and re-render the affected SwiftUI state. -6. Emit a fresh Experience event after a locale change when rendered output depends on SDK-derived - profile data, selected optimizations, flags, or MergeTags that must reflect the new - Experience/event locale. Use the event your app already owns for that state, such as `screen`, - `identify`, or `page` if used. +The iOS SDK does not fetch Contentful entries for your application UI — only the preview panel fetches +its own audience and experience definitions. Your app fetches entries from the Contentful Delivery +API and passes the resulting single-locale entry dictionaries to `OptimizedEntry` or +`client.resolveOptimizedEntry(...)`. There is no fetch-by-ID path in the iOS SDK, so the Contentful +client and its request options stay entirely yours. + +Fetch with one concrete locale and enough `include` depth to resolve the linked optimization data. +`nt_experiences` is the SDK-owned link field the resolver reads on an optimized entry; it links that +entry's `nt_experience` entries, and each experience links its `nt_variants` (and `nt_audience`). +These are fixed Optimization content-model identifiers you do not choose. `nt_config` is a JSON field +on the experience, not a link, so it needs no extra include depth. Fetch deep enough to pull the +linked entries back in one payload — the reference implementation uses `include=10`. Do not pass +all-locale CDA responses such as `locale=*`; the resolver expects direct single-locale field values +and falls back to baseline on an all-locale payload. + +The SDK Experience/event `locale` is distinct from the Contentful CDA locale: your app chooses the CDA +locale for its own fetch, and `OptimizationConfig(locale:)` sets the locale the Experience API and +events use. Keep them aligned when rendered content and Experience responses must match. + +1. Choose the application Contentful locale in your app's navigation, i18n, or account layer. +2. Pass the same locale to `OptimizationConfig(locale:)` when Experience responses and event context + must align with rendered content. +3. Fetch entries with a concrete locale and enough include depth for `nt_experiences` → + `nt_experience` → `nt_variants`/`nt_audience`. +4. When the app locale changes, call `client.setLocale(...)`, refetch entries with the new locale, and + re-render. `setLocale(...)` updates only the SDK Experience/event locale; it does not refetch + Contentful or refresh profile state, and it throws before initialization or on an invalid locale. -**Follow this pattern:** +**Adapt this to your use case:** ```swift let appLocale = selectedAppLocale() let config = OptimizationConfig( clientId: "", - environment: "main", - // Aligns Experience API responses and event context with rendered content. + // Aligns Experience API responses and event context with the rendered Contentful locale. locale: appLocale ) -// Fetch single-locale CDA entries; do not pass all-locale `locale=*` payloads. -let entry = await contentfulEntryClient.fetchEntry( - id: "", - include: 10, - locale: appLocale -) +// Your own CDA fetch: one concrete locale, include depth for linked experiences and variants. +let hero = await myContentfulFetcher.fetchEntry(id: "", locale: appLocale, include: 10) ``` For the full data shape and locale boundary, see @@ -359,26 +394,45 @@ and **Integration category:** Required for first integration -`OptimizedEntry` is the SwiftUI component for rendering Contentful entries through the Optimization -resolver. It passes non-optimized entries through unchanged, resolves optimized entries against the -visitor's selected variants, and falls back to the baseline entry when data is missing or unmatched. - -1. Pass the baseline Contentful entry dictionary to `OptimizedEntry`. -2. Render fields from the `resolvedEntry` value passed to the render closure. -3. Keep view-model conversion and SwiftUI rendering in the application layer. -4. Use `client.resolveOptimizedEntry(...)` directly only when a component needs to separate - resolution from rendering. +`OptimizedEntry` renders a Contentful entry through the resolver. It detects an optimized entry by the +presence of the `nt_experiences` field; a non-optimized entry passes through unchanged, and an +optimized entry resolves against the visitor's selected variants. The render closure receives the +resolved entry dictionary — the selected variant, or the baseline entry when no variant matches — +with the same field shape as the baseline, so your renderer reads fields without branching on whether +a variant was applied. + +Resolution is synchronous and fail-soft. `client.resolveOptimizedEntry(baseline:selectedOptimizations:)` +returns a `ResolvedOptimizedEntry`; if the client is not initialized, serialization fails, or the +bridge result cannot be parsed, it returns the baseline entry unchanged and logs a warning rather than +throwing or breaking the UI. The `selectedOptimizations` argument is the SDK's current per-experience +variant selections; pass `nil` (the default) to resolve against the SDK's live selection state, or +pass an explicit snapshot to resolve against exactly that. + +1. Pass the baseline Contentful entry dictionary to `OptimizedEntry` and read fields from the resolved + entry in the render closure. +2. Cast the resolved fields to your own model type in the closure; the entry keeps the baseline field + shape. +3. Provide your own loading treatment while the app-owned fetch is pending — `OptimizedEntry` needs an + entry to render, so gate it on your fetched state. +4. Use `client.resolveOptimizedEntry(...)` directly only when a component must separate resolution + from rendering. **Adapt this to your use case:** ```swift -struct CTASection: View { - let entry: [String: Any] +struct HeroSection: View { + // nil until your app-owned CDA fetch settles. + let entry: [String: Any]? var body: some View { - // The resolver falls back to the baseline entry when no variant matches. - OptimizedEntry(entry: entry) { resolvedEntry in - CTAHeader(entry: resolvedEntry) + if let entry { + OptimizedEntry(entry: entry) { resolvedEntry in + // resolvedEntry is the selected variant, or the baseline entry when none matches. + HeroCard(entry: resolvedEntry) + } + } else { + // Your own loading treatment; OptimizedEntry needs a fetched entry to render. + ProgressView() } } } @@ -392,35 +446,40 @@ struct DirectResolutionView: View { let entry: [String: Any] var body: some View { - // Use direct resolution only when rendering must be separate from OptimizedEntry. - let result = client.resolveOptimizedEntry( - baseline: entry, - selectedOptimizations: client.selectedOptimizations - ) - + // Resolve separately from rendering; omitting selectedOptimizations uses the SDK's live selection. + let result = client.resolveOptimizedEntry(baseline: entry) CTAHeader(entry: result.entry) } } ``` -Entry resolution is local and synchronous after the app has both Contentful entry data and SDK -optimization state. For fallback rules, see +Entry resolution is local and synchronous once the app has both the Contentful entry and SDK +optimization state. For the fallback rules, see [Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#fallback-behavior). ### Screen events and SwiftUI navigation **Integration category:** Required for first integration -Use `.trackScreen(name:)` on each SwiftUI screen or navigation destination. The modifier emits when -the view appears, when the screen name changes, and when consent changes allow the active screen to -be emitted. +You added `.trackScreen(name:)` in the quick start. The modifier calls `client.trackCurrentScreen(name:)` +when the view appears, when a consent change allows a previously blocked screen to emit, and when the +screen name changes. `trackCurrentScreen` dedupes in the bridge by route key (defaulting to the name), +so a repeat of the same current screen is skipped and a blocked attempt is retried once consent +allows. Plain `client.screen(name:)` emits with no dedupe. -1. Attach `.trackScreen(name:)` to the root view for every screen that maps to an analytics screen. +Attach `.trackScreen(name:)` once to a screen's stable root. For a dynamic screen name or an +app-defined route key — for example a detail screen whose name depends on loaded data — call +`client.trackCurrentScreen(name:properties:routeKey:)` from a task after the data is available +instead. Track a given route through one path only: do not attach `.trackScreen` and also call +`trackCurrentScreen`/`screen` for the same route, or you will emit duplicate or conflicting events. + +1. Attach `.trackScreen(name:)` to the stable root of each screen that maps to an analytics screen. 2. Use stable names for navigation destinations so downstream reporting can group events. -3. For dynamic screen events that need properties or an app-defined route key, call - `client.trackCurrentScreen(...)` from a SwiftUI task after the data is available. +3. For dynamic names or an explicit route key, call `client.trackCurrentScreen(name:properties:routeKey:)` + from a `.task` once the data is available. +4. Use one screen-tracking path per route. -**Copy this:** +**Follow this pattern:** ```swift struct HomeScreen: View { @@ -445,7 +504,7 @@ struct DetailsScreen: View { _ = try? await client.trackCurrentScreen( name: "BlogPostDetail", properties: ["postId": postId], - // Keeps retries and property updates tied to one logical route. + // Keeps dedupe and retries tied to one logical route across name changes. routeKey: "blog-post-\(postId)" ) } @@ -457,26 +516,43 @@ struct DetailsScreen: View { **Integration category:** Common but policy-dependent -`OptimizationRoot` defines tracking defaults for `OptimizedEntry` views. View and tap tracking -default to on; pass `trackViews: false` or `trackTaps: false` globally or per entry when a surface -must opt out. +`OptimizedEntry` tracks two interactions for the entry it wraps: entry views and entry taps (there is +no hover on iOS). Both default to enabled. `OptimizationRoot` sets the tree-wide defaults through its +`trackViews` and `trackTaps` parameters, and each `OptimizedEntry` can override them per entry. +`trackViews` and `trackTaps` are the configuration switches; on the wire an entry view is delivered as +a `component` event and a tap as a `component_click` event. Delivery is gated on consent: view +tracking checks `hasConsent("trackView")` and tap tracking checks `hasConsent("trackClick")`, so both +stay blocked until event consent (or an allow-list entry) permits them. + +View tracking is viewport-based. Wrap scrollable content in `OptimizationScrollView` so view timing +uses the real scroll position; without an enclosing scroll view, tracking assumes `scrollY` is `0` and +uses the screen height as the viewport, which suits only non-scrolling or already-visible layouts. The +default view threshold is 80% visibility (`minVisibleRatio` `0.8`) held for 2000 ms (`dwellTimeMs`); +after the first view event, duration updates emit every 5000 ms (`viewDurationUpdateIntervalMs`) while +the entry stays visible. + +A tap uses a SwiftUI `TapGesture` on the `OptimizedEntry` wrapper: it emits the `component_click` +event, then calls the optional `onTap` closure. That closure receives the **baseline** entry you +passed in, not the resolved variant — only the render closure receives the resolved entry — so do not +read variant-dependent fields from it. Because `onTap` runs through that same tap modifier, setting +`trackTaps: false` disables both the tap event and `onTap`. For app-only navigation that must not +depend on tap tracking, use a SwiftUI `Button` or your own gesture inside the render closure and read +the resolved entry's fields there. 1. Leave view and tap tracking enabled for entries that need exposure and interaction analytics. -2. Disable tap tracking where the wrapped entry does not represent tappable content. -3. Override `trackViews`, `trackTaps`, `dwellTimeMs`, `minVisibleRatio`, and - `viewDurationUpdateIntervalMs` on individual entries when needed. -4. Wrap scrollable content in `OptimizationScrollView` when view tracking needs accurate viewport - timing. -5. Render tappable UI inside `OptimizedEntry` when navigation needs fields from the resolved entry. +2. Set `trackViews: false` or `trackTaps: false` on `OptimizationRoot` for a tree-wide opt-out, or on + an individual `OptimizedEntry` for one surface. +3. Wrap scrollable entry lists in `OptimizationScrollView` for accurate viewport timing. +4. Tune `dwellTimeMs`, `minVisibleRatio`, and `viewDurationUpdateIntervalMs` per entry only when + analytics requirements differ from the defaults. +5. Use a `Button` or app gesture inside the render closure for navigation, and `onTap` only when the + SDK tap event should also drive it. -**Copy this:** +**Adapt this to your use case:** ```swift -OptimizationRoot( - config: config, - // Opt out globally only when this screen must not emit tap analytics. - trackTaps: false -) { +OptimizationRoot(config: config, trackTaps: false) { + // Tree-wide tap opt-out: no OptimizedEntry below emits component_click. RootView() } ``` @@ -487,7 +563,8 @@ OptimizationRoot( OptimizationScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(Array(posts.enumerated()), id: \.offset) { _, post in - OptimizedEntry(entry: post) { resolvedEntry in + // Per-entry thresholds override the tree defaults from OptimizationRoot. + OptimizedEntry(entry: post, minVisibleRatio: 0.5, dwellTimeMs: 1000) { resolvedEntry in BlogPostCard(entry: resolvedEntry) } } @@ -498,9 +575,19 @@ OptimizationScrollView { **Adapt this to your use case:** ```swift -OptimizedEntry(entry: cta) { resolvedEntry in +// SDK tap event plus app navigation. onTap fires after component_click, but it +// receives the baseline entry — so navigate with the resolved entry from the +// render closure instead, which carries the variant's fields. +OptimizedEntry(entry: cta, onTap: { _ in + analytics.log("cta-tapped") // A side effect that needs no variant fields. +}) { resolvedEntry in + CTAHeader(entry: resolvedEntry) + .onTapGesture { navigate(to: resolvedEntry) } +} + +// App-only navigation that must not depend on tap tracking: +OptimizedEntry(entry: cta, trackTaps: false) { resolvedEntry in Button { - // Use resolved fields here when the selected variant changes the destination. navigate(to: resolvedEntry) } label: { CTAHeader(entry: resolvedEntry) @@ -508,29 +595,36 @@ OptimizedEntry(entry: cta) { resolvedEntry in } ``` -Passing `trackTaps: false` on `OptimizedEntry` disables the SDK tap modifier for that entry. Because -the optional `onTap` callback runs through that modifier, `onTap` does not fire when tap tracking is -explicitly disabled. Use a SwiftUI `Button` or app-owned gesture inside the render closure for -app-only navigation, and read resolved fields there. For timing thresholds and event delivery -behavior, see +For timing thresholds, scroll context, and delivery behavior, see [iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). ### Identity, profile state, and reset **Integration category:** Common but policy-dependent -Identify users when your product has an application-owned user identity that can be sent to -Optimization. The SDK publishes profile, selected optimizations, changes, consent, and locale state -through `OptimizationClient`. +Identify a user when your product has an application-owned identity to associate with the profile. +`client.identify(userId:traits:)` links that identity to the current profile. The SDK publishes its +state reactively: `client.selectedOptimizations` and `client.locale` are top-level `@Published` +properties, and `client.state` publishes a snapshot carrying the profile, consent, and `changes` — +the inline field and flag values the Experience API returned for this visitor. SwiftUI views observe +any of them directly. Keep traits limited to values approved for Optimization profile use. + +When persistence consent allows it, the SDK stores profile continuity — profile, changes, selected +optimizations, and the anonymous id — in `UserDefaults` across app launches, reading it once at +startup and running from in-memory state thereafter. `client.reset()` clears that continuity (profile, +changes, selected optimizations, anonymous id, and the current-screen dedupe) but preserves the stored +consent decision, so the next SDK activity still follows the visitor's existing consent. `reset()` +no-ops before initialization. 1. Call `identify(userId:traits:)` from the authenticated flow or account state change that owns identity. -2. Read `client.state.profile` when SwiftUI needs to react to profile state. -3. Read `client.selectedOptimizations` only for app-owned resolution or diagnostics; - `OptimizedEntry` observes it automatically. -4. Call `client.reset()` when the user signs out or your policy requires clearing SDK-managed - profile continuity. -5. Re-emit a screen or page event after reset when the active journey needs fresh anonymous state. +2. Read `client.state.profile` when SwiftUI must react to profile state; read + `client.selectedOptimizations` only for app-owned resolution or diagnostics (`OptimizedEntry` + observes it for you). +3. Call `client.reset()` on sign-out or a privacy reset that must clear profile continuity. +4. Re-emit a screen event after reset when the active journey needs fresh anonymous state. +5. Use `client.consent(events:persistence:)` when profile-continuity persistence must differ from + event consent. **Adapt this to your use case:** @@ -542,16 +636,13 @@ struct AccountControls: View { VStack { Button("Identify") { Task { - // Identify after your app-owned authentication state is available. - _ = try? await client.identify( - userId: "user-123", - traits: ["plan": "pro"] - ) + // Identify once your app-owned authentication state is available. + _ = try? await client.identify(userId: "user-123", traits: ["plan": "pro"]) } } Button("Reset") { - // Clear SDK-managed profile continuity when the user signs out. + // Clears SDK-managed profile continuity; the stored consent decision survives. client.reset() } } @@ -559,34 +650,35 @@ struct AccountControls: View { } ``` -`reset()` clears SDK-managed profile, changes, selected optimizations, and anonymous ID continuity. -It preserves stored consent so the next SDK activity still follows the visitor's existing consent -decision. - ## Optional integrations ### Custom events and analytics diagnostics **Integration category:** Optional -Use custom events for application-owned business actions and SDK event streams for debug surfaces, -local validation, or forwarding to an application-owned analytics pipeline. +Use `client.track(event:properties:)` for application-owned business events, and the SDK event streams +for debug surfaces, local validation, or forwarding to your analytics pipeline. -1. Call `client.track(event:properties:)` from the SwiftUI event handler that owns the business - action. -2. Subscribe to `client.eventStream` before the events you need to observe. The public iOS stream is - a passthrough Combine publisher and does not replay prior events to late subscribers, even though - the underlying Core `states.eventStream` uses latest-value observable semantics. -3. Subscribe to `client.blockedEventStream` or configure `onEventBlocked` when a debug UI or logger - needs to explain consent-blocked events. -4. Keep downstream destination consent checks in the application layer before forwarding events. +`client.eventStream` is a passthrough Combine publisher fed by every emitted event; it does not replay +prior events to late subscribers, so subscribe before the events you want to observe (for example in +the root screen's `.task`, before child views can emit) or accept that earlier events are missed. This +is the programmatic observer the quick start pointed to: subscribe to `eventStream` to assert on the +accepted `screen` event in code instead of reading the Xcode console. `client.blockedEventStream` (and +the `onEventBlocked` config callback) surfaces events blocked by consent or the allow-list. Keep any +downstream destination consent checks in your app before forwarding. + +1. Call `client.track(event:properties:)` from the SwiftUI handler that owns the business action. +2. Subscribe to `client.eventStream` before the actions you need to observe; it does not buffer. +3. Subscribe to `client.blockedEventStream` or set `onEventBlocked` when a debug UI or logger must + explain consent-blocked events. +4. Apply destination consent in your app before forwarding events. **Adapt this to your use case:** ```swift struct AnalyticsDiagnostics: View { @EnvironmentObject private var client: OptimizationClient - @State private var lastEventType: String = "none" + @State private var lastEventType = "none" var body: some View { Text(lastEventType) @@ -607,16 +699,23 @@ For cross-SDK forwarding patterns, see **Integration category:** Optional -Custom Flags resolve from SDK change/profile response state. MergeTag entries come from app-fetched -Contentful or Rich Text payloads. `client.getMergeTagValue(mergeTagEntry:)` resolves them against -the current SDK Optimization profile and falls back to the entry fallback. The application still -decides where to render the values. +Custom Flags and merge tags read profile-backed values the Experience API returns, separately from +entry variant selection. `client.getFlag(_:)` is a one-time, non-reactive read; `client.flagPublisher(_:)` +returns a Combine publisher that updates as the flag value changes. Subscribing to a flag registers a +flag observation that emits a `component` flag-view event through the event stream when consent and +profile allow, so flag delivery is an analytics exposure — apply the same governance you use for other +SDK events. + +`client.getMergeTagValue(mergeTagEntry:)` resolves an inline `nt_mergetag` entry — the SDK-owned +merge-tag content-model identifier — against the current profile and returns the resolved string, or +`nil` when it cannot resolve. Your app owns extracting the embedded `nt_mergetag` entry from Rich Text +before calling it, and owns where the value renders. 1. Use `client.getFlag(_:)` for a one-time flag read after the SDK is initialized. -2. Use `client.flagPublisher(_:)` when SwiftUI state must update as flag values change. -3. Resolve Rich Text embedded `nt_mergetag` entries with `client.getMergeTagValue(mergeTagEntry:)` - after your Contentful fetcher has inlined the target entry. -4. Provide application-owned fallback rendering when a flag or MergeTag value is missing. +2. Use `client.flagPublisher(_:)` when SwiftUI state must follow flag changes. +3. Resolve Rich Text `nt_mergetag` entries with `client.getMergeTagValue(mergeTagEntry:)` after your + fetcher has inlined the target entry. +4. Provide app-owned fallback rendering when a flag or merge-tag value is missing. **Adapt this to your use case:** @@ -642,29 +741,25 @@ struct FlaggedBadge: View { } ``` -One-time flag reads and flag subscriptions can attempt flag-view tracking when consent or the -allow-list and current profile allow it, so apply the same analytics governance you use for other -SDK events. - ### Live updates **Integration category:** Optional -By default, `OptimizedEntry` locks to the first variant it resolves so content does not change while +By default, `OptimizedEntry` locks to the first variant it resolves, so content does not change while a visitor is reading it. Enable live updates when a screen must react to profile changes or preview overrides without a reload. 1. Set `liveUpdates: true` on `OptimizationRoot` when most optimized entries in the tree must update as SDK state changes. 2. Set `liveUpdates: true` on an individual `OptimizedEntry` for a localized live section. -3. Set `liveUpdates: false` on an individual `OptimizedEntry` when that entry must remain locked - even under a live global default. +3. Set `liveUpdates: false` on an individual `OptimizedEntry` to keep it locked even under a live + global default. 4. Expect the preview panel to force live updates while it is open so overrides apply immediately. **Adapt this to your use case:** ```swift -// Makes entries update when SDK profile state or preview overrides change. +// Root default: entries update as SDK profile state or preview overrides change. OptimizationRoot(config: config, liveUpdates: true) { RootView() } @@ -679,23 +774,27 @@ OptimizedEntry(entry: legalCopyEntry, liveUpdates: false) { resolvedEntry in } ``` -When the preview panel closes, locked `OptimizedEntry` components keep the previewed variant as the -locked value. For precedence rules, see +The resolution order is: an open preview panel forces live updates, then a per-entry `liveUpdates` +value, then the `OptimizationRoot` `liveUpdates` default, then the locked default. When the preview +panel closes, a locked `OptimizedEntry` snapshots the current selections so applied overrides persist. +For the precedence rules, see [iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). ### Preview panel **Integration category:** Optional -Use the preview panel only in debug or internal builds. `PreviewPanelConfig` is the preferred -SwiftUI path because `OptimizationRoot` can mount `PreviewPanelOverlay` for you. +Use the preview panel only in debug or internal builds. `PreviewPanelConfig` is the preferred SwiftUI +path because `OptimizationRoot` mounts `PreviewPanelOverlay` for you. The panel fetches `nt_audience` +and `nt_experience` definitions — the SDK-owned audience and experience content types — through an +app-supplied `PreviewContentfulClient`, then lets users override audiences and variants locally. 1. Gate the panel behind a debug, internal, or feature-flag condition. 2. Pass `PreviewPanelConfig(enabled: false)` in builds where the panel must not render. -3. Pass a `PreviewContentfulClient` when the panel needs audience and experience names instead of - raw identifiers. -4. Use `ContentfulHTTPPreviewClient` for a direct CDA-backed panel, or implement - `PreviewContentfulClient` around your existing Contentful client. +3. Pass a `PreviewContentfulClient` so the panel shows audience and experience names instead of raw + identifiers. +4. Use `ContentfulHTTPPreviewClient` for a direct CDA-backed panel, or implement `PreviewContentfulClient` + around your existing Contentful client. **Adapt this to your use case:** @@ -713,16 +812,13 @@ let previewPanel = PreviewPanelConfig( let previewPanel = PreviewPanelConfig(enabled: false) #endif -OptimizationRoot( - config: config, - // OptimizationRoot mounts the floating preview panel only when enabled. - previewPanel: previewPanel -) { +OptimizationRoot(config: config, previewPanel: previewPanel) { RootView() } ``` -`PreviewPanelOverlay` remains available when the app needs to place the floating action button +`PreviewPanelOverlay` reads the client from the SwiftUI environment, so it must sit under an +`OptimizationRoot`. It remains available when the app needs to place the floating action button manually, but `PreviewPanelConfig` keeps the setup attached to the root SDK provider. ## Advanced integrations @@ -738,8 +834,8 @@ event allow-lists, non-default endpoints, or queue observability. 2. Pass a narrow `allowedEventTypes` list when policy permits only specific pre-consent events. 3. Configure `OptimizationApiConfig` only for approved non-default Experience API or Insights API endpoints. -4. Configure `onEventBlocked` or `blockedEventStream` when release validation needs proof that - denied events are blocked. +4. Configure `onEventBlocked` or subscribe to `blockedEventStream` when release validation needs proof + that denied events are blocked. 5. Configure `QueuePolicy` only when production operations need non-default queue limits, retry timing, or queue callback telemetry. @@ -748,7 +844,6 @@ event allow-lists, non-default endpoints, or queue observability. ```swift let config = OptimizationConfig( clientId: "", - environment: "main", api: OptimizationApiConfig( experienceBaseUrl: "", insightsBaseUrl: "" @@ -756,14 +851,11 @@ let config = OptimizationConfig( // Blocks every SDK event until explicit consent is accepted. allowedEventTypes: [], queuePolicy: QueuePolicy( - flush: QueueFlushPolicy( - flushIntervalMs: 1000, - maxConsecutiveFailures: 3 - ), + flush: QueueFlushPolicy(flushIntervalMs: 1000, maxConsecutiveFailures: 3), offlineMaxEvents: 100 ), onEventBlocked: { blocked in - // Verification hook for confirming denied events do not leave the SDK. + // Verification hook: confirm denied events do not leave the SDK. debugLogger.info("Blocked \(blocked.method): \(blocked.reason)") } ) @@ -773,9 +865,11 @@ let config = OptimizationConfig( **Integration category:** Advanced or production-only -The iOS SDK monitors network reachability and app lifecycle events after initialization. Events -queue while the device is offline, flush when connectivity returns, and flush again when the app -moves toward the background. +After initialization the SDK monitors network reachability and app lifecycle. A `NetworkMonitor` +(`NWPathMonitor`) calls `setOnline(_:)` on connectivity changes and `flush()` on reconnect; on UIKit +an `AppStateHandler` calls `flush()` when the app resigns active for a best-effort background drain. +Queues are in-memory only — there is no durable outbox — and the offline Experience buffer is capped +at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`); nothing survives process death. 1. Keep one `OptimizationClient` alive for the app or scene lifetime so the in-memory queue can survive transient network changes. @@ -783,14 +877,14 @@ moves toward the background. network simulation. 3. Call `client.flush()` from app-owned shutdown or critical-flow checkpoints when policy requires a best-effort delivery attempt before leaving the flow. -4. Use the queue callbacks in `QueuePolicy` when operations teams need telemetry for offline drops, - flush failures, circuit-open events, or recovery. +4. Use the `QueuePolicy` callbacks when operations teams need telemetry for offline drops, flush + failures, circuit-open events, or recovery. **Follow this pattern:** ```swift Task { - // Use app-owned checkpoints for a best-effort delivery attempt before leaving a flow. + // Best-effort delivery attempt before leaving a critical flow. try? await client.flush() } ``` @@ -802,43 +896,43 @@ For deeper runtime behavior, see Before release, verify these checks against the target app build: -- Confirm the app uses the intended Optimization client ID, environment, SDK locale, and any - approved Experience API or Insights API endpoint overrides. -- Confirm the Contentful client fetches single-locale entries with enough include depth for - optimized entries, and verify baseline rendering still works when no variant matches. -- Confirm the consent flow covers default-on, accepted, rejected, and split event/persistence cases - that apply to your policy. -- Confirm screen, entry view, entry tap, Custom Flag, and custom business events are accepted or - blocked according to consent state. -- Confirm `.trackScreen(name:)` is attached once per logical screen and does not duplicate events - during SwiftUI navigation transitions. -- Confirm scrollable entry lists use `OptimizationScrollView` when viewport-aware view timing is - required. -- Confirm sign-out or privacy-reset flows call `reset()` when profile continuity must be cleared, - and confirm application-owned identifiers are cleared outside the SDK. -- Confirm the preview panel is absent from public builds or gated to approved internal users. -- Confirm analytics forwarding code applies destination consent and does not replay events that the - SDK blocked. -- Validate locally with the iOS reference implementation or the app's own targeted XCUITest flow - before relying on production telemetry. +- **Credentials and runtime configuration** — the app uses the intended Optimization client ID and + environment, the SDK Experience/event locale, and any approved Experience API or Insights API + endpoint overrides; mock or localhost base URLs are absent from production configuration. +- **Consent behavior** — default-on consent is used only when policy permits it; user-choice flows + call `consent(true | false)`; split event/persistence consent matches your persistence policy; and + rejected consent blocks non-allowed event types. +- **Event delivery** — screen, entry view, entry tap, Custom Flag, and custom business events are + accepted or blocked according to consent state, and offline replay plus background flush behave as + expected on your supported platforms. +- **Content fallback** — the Contentful client fetches single-locale entries with enough include depth + for optimized entries, and baseline rendering still works when no variant matches or data is + incomplete. +- **Duplicate-tracking prevention** — one `OptimizationRoot` owns the SwiftUI tree, each route uses + one screen-tracking path, `.trackScreen(name:)` is attached once per logical screen, and the app + does not wrap the same rendered entry more than once for one impression. +- **Privacy and governance** — forwarded analytics payloads apply destination consent and do not + replay events the SDK blocked, profile traits are approved, the preview panel is absent from public + builds or gated to approved internal users, and persisted profile continuity matches consent + records. +- **Local validation path** — validate against the iOS reference implementation or the app's own + targeted XCUITest flow before relying on production telemetry. ## Troubleshooting Use these checks for common SwiftUI integration failures: -| Symptom | Check | -| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Personalized content stays baseline | Verify consent policy permits optimization, `identify` or screen events produce selected optimizations, CDA payloads are single-locale, and linked variants are included. | -| Entry view events do not appear | Verify `trackViews` was not opted out, the entry is visible for at least the dwell threshold, consent permits `trackView`, and scrollable content uses `OptimizationScrollView`. | -| Entry tap events do not appear | Verify `trackTaps` was not opted out globally or per entry, consent permits `trackClick`, and the entry has a Contentful `sys.id` for component metadata. | -| Screen events duplicate or go missing | Attach `.trackScreen(name:)` to the stable screen root, and use an explicit `routeKey` when a dynamic screen name can change for the same logical route. | -| Preview panel shows identifiers only | Pass a `PreviewContentfulClient` so the panel can fetch audience and experience definitions from Contentful. | -| Flag values do not update with `.sink` | Subscribe after `OptimizationRoot` initializes, retain the returned `AnyCancellable` for as long as the view model needs updates, and verify the flag key exists in SDK `changes` state. | -| Flag values do not update with `.values` | Subscribe after `OptimizationRoot` initializes, keep the Swift concurrency task alive for as long as the view needs updates, and verify the flag key exists in SDK `changes` state. | +| Symptom | Check | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Personalized content stays baseline | Confirm consent permits optimization, a `screen` or `identify` event has produced selected optimizations, the CDA payload is single-locale (not `locale=*`), and linked variants are included deeply enough. | +| Entry view or tap events are missing | Confirm `trackViews`/`trackTaps` were not opted out, consent permits `trackView`/`trackClick`, the entry stayed visible past the dwell threshold, scrollable content uses `OptimizationScrollView`, and the entry has a `sys.id`. | +| Screen events duplicate or go missing | Attach `.trackScreen(name:)` once to the stable screen root, use one screen-tracking path per route, and pass an explicit `routeKey` when a dynamic screen name can change for the same logical route. | +| Preview panel shows identifiers only | Pass a `PreviewContentfulClient` so the panel can fetch `nt_audience` and `nt_experience` definitions and show names instead of raw IDs. | +| Flag values do not update | Subscribe after `OptimizationRoot` initializes, keep the Combine subscription or Swift concurrency task alive for as long as the view needs updates, and verify the flag key exists in SDK change/profile state. | ## Reference implementations to compare against -- [iOS reference implementation](../../implementations/ios-sdk/README.md) - Demonstrates SwiftUI and +- [iOS reference implementation](../../implementations/ios-sdk/README.md) — the maintained SwiftUI and UIKit shells that exercise shared native iOS bridge behavior, single-locale Contentful fetching, entry resolution, interaction tracking, screen tracking, Custom Flags, offline delivery, and preview-panel overrides against the same mock API. diff --git a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md index c54a3056..197f5a58 100644 --- a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md +++ b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md @@ -1,120 +1,197 @@ # Integrating the Optimization iOS SDK in a UIKit app -Use this guide when you want to add Optimization, Analytics, screen tracking, entry interaction -tracking, and preview overrides to a UIKit application using the `ContentfulOptimization` Swift -Package. - -Use the SwiftUI guide instead when your app renders optimized entries through SwiftUI views: -[Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md). +Use this guide to add Contentful personalization to a UIKit app with the `ContentfulOptimization` +Swift Package. By the end of the quick start, the SDK is running in your scene and one screen event +has passed the SDK's consent gate, with a visible label confirming it. + +**New to personalization?** Here is the whole idea in five points: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- As the app runs, Contentful's **Experience API** looks at who the visitor is and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- The Experience API also returns a **profile**: the anonymous, per-visitor identity and state used + to keep personalization consistent across requests or app launches. +- Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK + gives back the selected variant, or the original entry when no variant applies—the **baseline + fallback**. +- You render the returned entry with the same application components you already use. + +The iOS SDK persists the profile in `UserDefaults` across app launches when persistence consent +allows it. + +That is enough to start. The guide introduces policy and optional capabilities at the point you need +them. + +You will get there in two milestones: + +- **Milestone 1 — the SDK initialized in your scene and one accepted screen event (the quick start + below).** Once your app also hands the SDK a fetched Contentful entry, that entry resolves to a + variant or the baseline through `resolveOptimizedEntry` (the + [Contentful fetching and entry resolution](#contentful-fetching-and-entry-resolution) section). + This is complete and shippable on its own. +- **Milestone 2 — the opt-in layers (later).** Consent handoff, interaction tracking, identity, + Custom Flags, live updates, the preview panel, runtime locale changes, and offline delivery, each + introduced by the section that needs it. Start with + [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff). + +This guide uses `ContentfulOptimization`. UIKit apps drive the SDK through the imperative +`OptimizationClient`: you create and initialize one client, hold it for the scene or app lifetime, +and inject it into the view controllers that track events or resolve entries. The SDK does not +replace your app's Contentful client — your UIKit app still owns Contentful fetching, link +resolution, consent UX, identity policy, navigation, caching, and rendering. If your app renders +through SwiftUI views instead, use the +[Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) +guide. ## Quick start -This path proves the SDK can initialize in a UIKit scene and emit one screen event. It assumes -application policy permits accepted SDK startup. If your app must wait for a CMP or consent UI, -leave `defaults` unset and wire consent in the [consent handoff](#consent-handoff) section. Strict -opt-in apps must also pass `allowedEventTypes: []` before enabling gated events. - -1. Add the `ContentfulOptimization` Swift Package from - `https://github.com/contentful/optimization.swift` to the app target. -2. Create one `OptimizationClient` for the scene or app lifetime. -3. Initialize the client with the Optimization client ID, Contentful environment, and accepted - startup consent. -4. Track the current screen from `viewDidAppear(_:)`. -5. Verify the visible label changes to `Optimization screen event accepted`. - -**Copy this:** - -```swift -import ContentfulOptimization -import UIKit - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - - // Own one SDK client for the scene or app lifetime, then inject this same - // instance into UIKit controllers that resolve entries or track events. - private let client = OptimizationClient() - - func scene( - _ scene: UIScene, - willConnectTo _: UISceneSession, - options _: UIScene.ConnectionOptions - ) { - guard let windowScene = scene as? UIWindowScene else { return } - - try? client.initialize(config: OptimizationConfig( - clientId: "your-client-id", - environment: "main", - // Use accepted startup only when your app's consent policy permits - // SDK event emission before showing a consent UI. - defaults: StorageDefaults(consent: true) - )) - - let home = HomeViewController(client: client) - window = UIWindow(windowScene: windowScene) - window?.rootViewController = UINavigationController(rootViewController: home) - window?.makeKeyAndVisible() - } -} - -final class HomeViewController: UIViewController { - private let client: OptimizationClient - private let statusLabel = UILabel() - - init(client: OptimizationClient) { - self.client = client - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - override func viewDidLoad() { - super.viewDidLoad() - statusLabel.text = "Waiting for Optimization" - statusLabel.textAlignment = .center - statusLabel.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(statusLabel) - NSLayoutConstraint.activate([ - statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - Task { @MainActor in - // Track the current screen after UIKit has made it visible, so - // verification matches a real navigation lifecycle and repeated - // callbacks are deduplicated by the SDK. - let result = try? await client.trackCurrentScreen(name: "Home") - if result?.accepted == true { - statusLabel.text = "Optimization screen event accepted" - } else { - statusLabel.text = "Optimization screen event blocked" - } +Most UIKit + Contentful apps share one shape: a `SceneDelegate` builds the window and a root view +controller, and a `UIViewController` presents content. This quick start assumes that shape and proves +the smallest result: **the SDK initializes in your scene and one screen event is accepted, and a +visible label flips to confirm it.** It owns one `OptimizationClient` in the scene, initializes it, +injects it into the first view controller, and tracks the current screen from `viewDidAppear(_:)`. + +This quick start assumes your application policy permits Optimization to start with accepted consent +and renders no end-user consent UI, so it seeds `StorageDefaults(consent: true)` — the shorthand that +accepts both consent axes at once. If personalization must wait for a consent decision, keep this +structure and add the [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step +before you ship, which explains the two axes and the split form that sets them separately. + +1. Add the `ContentfulOptimization` Swift Package to your app target from + `https://github.com/contentful/optimization.swift` (in Xcode: **File > Add Package + Dependencies**), then build and run the app target once so Swift Package Manager resolves and + compiles the package. The package supports iOS 15+ and macOS 12+. + +2. Own one client in your existing `SceneDelegate`, initialize it, and inject it into your first view + controller. `initialize(config:)` is synchronous and `throws` (it runs bridge setup inline on the + main actor), so call it with `try`/`try?` and no `await`. + + **Adapt this to your use case:** + + ```diff + import UIKit + +import ContentfulOptimization + + final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + + // Own one client for the whole scene, then inject this same instance + + // into the view controllers that track events or resolve entries. + + let client = OptimizationClient() + + func scene( + _ scene: UIScene, + willConnectTo _: UISceneSession, + options _: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + + // Synchronous throws, not async: call with try/try? and no await. + + // StorageDefaults seeds accepted consent at startup for this proof. + + try? client.initialize(config: OptimizationConfig( + + clientId: "your-optimization-client-id", + + defaults: StorageDefaults(consent: true), + + logLevel: .debug, + + onEventBlocked: { blocked in + + // If the label reads "blocked", this prints why. + + print("Optimization blocked \(blocked.method): \(blocked.reason)") + + } + + )) + + - let home = HomeViewController() + + let home = HomeViewController(client: client) + window = UIWindow(windowScene: windowScene) + window?.rootViewController = UINavigationController(rootViewController: home) + window?.makeKeyAndVisible() } } -} -``` + ``` + + The unchanged lines above are illustrative context to match against your own `SceneDelegate`, not + a block to paste over it. `StorageDefaults` is an SDK config type; `StorageDefaults(consent: true)` + grants both consent axes at startup. + +3. Track the current screen from a view controller and reflect the outcome in a label. `HomeViewController` + below is illustrative app shape — adapt it to a screen you already render, keeping the + client-injection initializer and the `trackCurrentScreen` call in `viewDidAppear`. + + **Adapt this to your use case:** + + ```swift + import ContentfulOptimization + import UIKit + + final class HomeViewController: UIViewController { + private let client: OptimizationClient + private let statusLabel = UILabel() + + init(client: OptimizationClient) { + self.client = client + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + statusLabel.text = "Waiting for Optimization" + statusLabel.textAlignment = .center + statusLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(statusLabel) + NSLayoutConstraint.activate([ + statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + Task { @MainActor in + // Track the current screen once UIKit has made it visible. + let result = try? await client.trackCurrentScreen(name: "Home") + statusLabel.text = result?.accepted == true + ? "Optimization screen event accepted" + : "Optimization screen event blocked" + } + } + } + ``` + +4. Verify the first run. Launch the app; the label reads `Optimization screen event accepted`. + `trackCurrentScreen` returns an `EventEmissionResult` — an SDK result type whose `accepted` flag is + `true` when the event passed the SDK's local consent and allow-list gate and was emitted or queued + for delivery. `accepted` does not confirm that Contentful received the event, only that the local + gate let it through. Because `StorageDefaults(consent: true)` grants consent and `screen` is on the + SDK's default pre-consent allow-list, the event is accepted. If the label reads + `Optimization screen event blocked` instead, there are two causes to tell apart. If the consent + gate rejected the event, the `onEventBlocked` callback prints a line prefixed `Optimization blocked` + naming the reason and method — search the Xcode console for that prefix. If instead the client + never initialized (for example a wrong `clientId`), `try?` in step 2 swallowed the thrown error, so + `onEventBlocked` never fires; the SDK's `logLevel: .debug` output under the + `com.contentful.optimization` subsystem shows the failed init. To see the init error directly, + temporarily replace `try?` with a `do { try client.initialize(...) } catch { print(error) }` block.
Table of Contents -- [Required setup](#required-setup) +- [Before you start](#before-you-start) - [Core integration](#core-integration) - [Package installation and SDK configuration](#package-installation-and-sdk-configuration) - [Client lifetime and UIKit injection](#client-lifetime-and-uikit-injection) - - [Consent handoff](#consent-handoff) + - [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) - [Contentful fetching and entry resolution](#contentful-fetching-and-entry-resolution) - - [Screen, custom event, and entry tracking](#screen-custom-event-and-entry-tracking) + - [Screen and navigation tracking](#screen-and-navigation-tracking) + - [Entry interaction tracking](#entry-interaction-tracking) - [Identity, profile continuity, and reset](#identity-profile-continuity-and-reset) - [Optional integrations](#optional-integrations) + - [Custom events and analytics diagnostics](#custom-events-and-analytics-diagnostics) + - [Custom Flags and MergeTag rendering](#custom-flags-and-mergetag-rendering) - [Live updates and locked variants](#live-updates-and-locked-variants) - [Preview panel](#preview-panel) - - [Custom Flags and debug event streams](#custom-flags-and-debug-event-streams) - [Runtime locale changes](#runtime-locale-changes) - [Advanced integrations](#advanced-integrations) - [Offline delivery, queue observability, and app-owned caching](#offline-delivery-queue-observability-and-app-owned-caching) @@ -125,30 +202,37 @@ final class HomeViewController: UIViewController {
-## Required setup - -Use this setup inventory for the full UIKit guide: - -| Setup item | Category | Required for quick start | Where to configure | -| -------------------------------------------------------------------------- | ------------------------------ | ------------------------ | -------------------------------------------------------------------------- | -| `ContentfulOptimization` Swift Package | Required for first integration | Yes | Xcode Swift Package Manager or the app target's `Package.swift` | -| Optimization client ID and Contentful environment | Required for first integration | Yes | `OptimizationConfig(clientId:environment:)` | -| App-owned Contentful Delivery API client, credentials, and concrete locale | Required for first integration | No | Application Contentful service or repository layer | -| Single-locale Contentful entry payloads with linked optimization entries | Required for first integration | No | CDA or CPA requests with `include` depth and a non-wildcard `locale` | -| Scene or app coordinator that owns one `OptimizationClient` | Required for first integration | Yes | `SceneDelegate`, `AppDelegate`, or an app-level dependency container | -| UIKit view, cell, or wrapper that resolves entries before rendering | Required for first integration | No | `UIViewController`, `UITableViewCell`, `UICollectionViewCell`, or `UIView` | -| Consent and privacy startup policy | Common but policy-dependent | Conditional | `StorageDefaults`, app consent UI, CMP callback, or account preference | -| Pre-consent event allow-list | Common but policy-dependent | Conditional | `OptimizationConfig.allowedEventTypes` | -| Screen lifecycle hook | Required for first integration | Yes | `viewDidAppear(_:)`, navigation coordinator, or app router | -| Route-key naming for duplicate screen prevention | Common but policy-dependent | No | `trackCurrentScreen(name:properties:routeKey:)` or app router | -| Entry tap and view-tracking metadata | Common but policy-dependent | Conditional | UIKit control actions, gesture recognizers, scroll-view geometry, or cells | -| Identity and profile-continuity policy | Common but policy-dependent | No | Sign-in, account, consent, and reset flows | -| Custom Flag reads and analytics debug streams | Optional | No | Feature surfaces, debug views, or app analytics forwarding layer | -| Preview-panel Contentful client and internal access gate | Optional | No | Debug or internal-build preview setup | -| Queue policy, offline diagnostics, and app-owned content cache policy | Advanced or production-only | No | `OptimizationConfig(queuePolicy:)`, app telemetry, and content cache code | - -The SDK does not replace the app's Contentful client. Your UIKit app still owns Contentful fetching, -link resolution, consent UX, identity policy, navigation, caching, and rendering. +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- **A UIKit app and Xcode**, with your own Contentful fetching already working and the ability to add + a Swift package and run an Xcode build. The SDK is added through Swift Package Manager and supports + iOS 15+ and macOS 12+. +- **Contentful delivery credentials** — space ID, delivery token, environment, and one concrete + locale — read from your app's configuration layer. +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant, the integration can still run correctly while returning the baseline, so you + cannot yet distinguish working personalization from a content-authoring gap. For the first + personalized-content test, target all visitors so the test request or visitor matches automatically. +- **Your Optimization project values** — client ID and environment, from your Optimization project + settings. The `environment` defaults to `main`, so pass it only when your setup differs. The + Experience API (which picks variants) and the Insights API (which receives event and interaction + delivery) each have a base URL that defaults correctly; you only set them for mocks or non-default + hosts (see [Package installation and SDK configuration](#package-installation-and-sdk-configuration)). + +You do not need a setup inventory up front. Everything else — consent, entry resolution, screen +tracking, interaction tracking, identity, live updates, preview, runtime locale changes, offline +delivery — is introduced by the section that needs it. + +> [!NOTE] +> +> Read the SDK client ID, Contentful credentials, and any base-URL overrides from your app's own +> configuration layer — an xcconfig value, a build setting, or a generated config type. This guide's +> examples use inline placeholder strings for clarity; the iOS reference app centralizes these in a +> shared `AppConfig` because it runs against shared mock defaults. Use whatever configuration +> convention your app already uses and keep it consistent. ## Core integration @@ -156,11 +240,12 @@ link resolution, consent UX, identity policy, navigation, caching, and rendering **Integration category:** Required for first integration -Add the package from `https://github.com/contentful/optimization.swift`, then initialize the SDK -with the Optimization client ID and the environment that matches your Contentful setup. The package -requires iOS 15 or later. +Add the package from `https://github.com/contentful/optimization.swift`, then build and run the app +target once in Xcode so Swift Package Manager resolves and compiles it before you wire the client. +Most apps add it through Xcode's **File > Add Package Dependencies**; SwiftPM-manifest targets add it +in `Package.swift`. -**Copy this:** +**Adapt this to your use case:** ```swift dependencies: [ @@ -176,122 +261,103 @@ targets: [ ], ``` -1. Add the `ContentfulOptimization` product to the app target. -2. Choose the app's Contentful locale, such as `"en-US"`. -3. Pass that same locale to SDK `locale` when Experience API requests and event context need to use - the same language as the rendered Contentful entries. -4. Keep `logLevel` at its default `.error` for production unless your operational policy explicitly - allows more verbose logging. +Configure the SDK with your Optimization client ID and the environment that matches your Contentful +setup. Only `clientId` is required by the initializer. -**Copy this:** +1. Pass `clientId` from your configuration layer. +2. Pass `environment` only when it is not the default `main`. +3. Pass `locale` when Experience API requests and event context must use the same language as the + Contentful entries you render. +4. Set `api` base URLs (`experienceBaseUrl`/`insightsBaseUrl`) only for mock, staging, or other + non-default endpoints — both default correctly otherwise. +5. Keep `logLevel` at its default `.error` in production unless your operational policy allows more + verbose logging. + +**Adapt this to your use case:** ```swift let appLocale = "en-US" let config = OptimizationConfig( - clientId: "your-client-id", - environment: "main", - // Keep SDK event and Experience locale aligned with rendered CDA entries - // when the screen uses localized Contentful content. + clientId: "your-optimization-client-id", + // environment defaults to "main"; pass it only when your setup differs. + // Keep SDK event and Experience locale aligned with rendered CDA entries. locale: appLocale ) ``` -Only `clientId` is required by the initializer. `environment` defaults to `"main"`, and `locale` is -omitted unless you pass it. Use API base URL overrides only for mock, test, or non-default API -endpoints. For package-level installation notes, see the +For package-level installation notes, see the [Optimization iOS SDK README](../../packages/ios/ContentfulOptimization/README.md). ### Client lifetime and UIKit injection **Integration category:** Required for first integration -UIKit integrations use `OptimizationClient` directly. Keep one initialized client alive for the -scene or app lifetime, then inject that instance into every controller or view that resolves entries -or tracks events. +UIKit integrations use `OptimizationClient` directly. Keep one initialized client alive for the scene +or app lifetime, then inject that instance into every controller or view that resolves entries or +tracks events. -1. Create the client in `SceneDelegate`, `AppDelegate`, or an app-level dependency container. -2. Call `initialize(config:)` before presenting content that uses Optimization. -3. Pass the initialized client through initializers instead of creating separate clients in child +1. Create the client in `SceneDelegate`, `AppDelegate`, or an app-level dependency container, and + call `initialize(config:)` before presenting content that uses Optimization. +2. Pass the initialized client through initializers instead of creating separate clients in child controllers. -4. Return to the main actor before calling the client from asynchronous callbacks. - `OptimizationClient` is `@MainActor`. +3. Return to the main actor before calling the client from asynchronous callbacks; `OptimizationClient` + is `@MainActor`. +4. Gate UI on readiness when needed: the client publishes `isInitialized`, so observe + `client.$isInitialized` when a screen must wait for setup before it reads SDK state. **Adapt this to your use case:** ```swift -import ContentfulOptimization -import UIKit - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - - // Keep this initialized client alive across UIKit navigation. - private let client = OptimizationClient() +final class ProductViewController: UIViewController { + private let client: OptimizationClient - func scene( - _ scene: UIScene, - willConnectTo _: UISceneSession, - options _: UIScene.ConnectionOptions - ) { - guard let windowScene = scene as? UIWindowScene else { return } + // Inject the app-owned client instead of creating a new one here. + init(client: OptimizationClient) { + self.client = client + super.init(nibName: nil, bundle: nil) + } - // Initialize before presenting screens that resolve entries or track events. - try? client.initialize(config: config) + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - let root = HomeViewController(client: client) - window = UIWindow(windowScene: windowScene) - window?.rootViewController = UINavigationController(rootViewController: root) - window?.makeKeyAndVisible() + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + Task { @MainActor in + // Hop back to the main actor before any client call. + _ = try? await client.trackCurrentScreen(name: "ProductList") + } } } ``` -Use `destroy()` for test teardown or a deliberate SDK teardown flow, not for normal navigation +Use `destroy()` only for test teardown or a deliberate SDK teardown flow, not for normal navigation between UIKit screens. For lifecycle and main-actor mechanics, see [iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-main-actor). -### Consent handoff +### Consent and privacy-policy handoff **Integration category:** Common but policy-dependent -Consent policy remains application-owned. The SDK provides the runtime gate; your app or CMP owns -notice, user choices, consent records, jurisdiction logic, and withdrawal behavior. - -1. Use default-on accepted startup only when application policy permits SDK activity at launch. -2. Leave `defaults` unset when the app must collect a choice before gated Analytics events can emit. -3. Pass `allowedEventTypes: []` when strict opt-in policy means no Optimization event can emit - before consent. -4. Call `client.consent(true)` or `client.consent(false)` from the app's consent controls. -5. Use split consent when event emission and durable profile continuity have separate policy - decisions. -6. Observe `client.$state` when the UI needs to reflect event consent or persistence consent. - -**Copy this:** - -```swift -let config = OptimizationConfig( - clientId: "your-client-id", - // Accepted startup consent enables gated Analytics events immediately. - defaults: StorageDefaults(consent: true) -) -``` +Consent policy belongs to your application. The SDK provides the runtime gate; your app or CMP owns +notice, user choices, consent records, jurisdiction logic, and withdrawal behavior. Consent has two +independent axes: event consent (may the SDK personalize and emit events) and persistence consent +(may the SDK store profile continuity in `UserDefaults`). -**Copy this:** - -```swift -let config = OptimizationConfig( - clientId: "your-client-id", - // Replaces the native default pre-consent allow-list of identify and screen. - allowedEventTypes: [] -) -``` +1. Use `StorageDefaults(consent: true)` at startup only when application policy permits SDK activity + at launch. +2. Leave `defaults` unset when the app must collect a choice before gated events can emit, and call + `consent(...)` from the app-owned banner, CMP callback, or settings flow. +3. Use `consent(_:)` for the boolean shorthand that sets both axes, or `consent(events:persistence:)` + to set them independently. +4. Pass `allowedEventTypes: []` for strict opt-in, so no SDK event emits before event consent. +5. Observe `client.$state` when the UI must reflect event consent or persistence consent. **Adapt this to your use case:** ```swift @objc private func acceptTapped() { - // Wire this to the app-owned consent UI or CMP callback. + // Boolean consent sets both event emission and durable profile continuity. client.consent(true) } @@ -300,30 +366,59 @@ let config = OptimizationConfig( } @objc private func allowEventsOnlyTapped() { + // Split consent: emit events but keep profile continuity session-only. client.consent(events: true, persistence: false) } ``` -Boolean consent controls both event emission and durable profile-continuity persistence by default. -When `allowedEventTypes` is unset, the native default allow-list lets `identify` and `screen` emit -before consent so a mobile journey can establish profile context and anonymous screen analytics. -Custom `allowedEventTypes` replaces that default, and `allowedEventTypes: []` blocks every SDK event -until consent is accepted. For the full consent responsibility model, see +When `allowedEventTypes` is unset, the SDK's default pre-consent allow-list lets `identify` and +`screen` emit before event consent, so a mobile journey can establish profile context and anonymous +screen analytics. Entry views, entry taps, and custom `track` events are blocked until +consent is accepted. A custom `allowedEventTypes` replaces that default, and `allowedEventTypes: []` +blocks every SDK event until consent is accepted. `consent(false)` clears both axes, purges queues, +and clears durable continuity while in-memory state stays usable until reset or teardown. + +**Adapt this to your use case:** + +```swift +let config = OptimizationConfig( + clientId: "your-optimization-client-id", + // Replaces the default pre-consent allow-list of identify and screen with + // strict opt-in: nothing emits until consent is accepted. + allowedEventTypes: [] +) +``` + +For the full consent responsibility model, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). ### Contentful fetching and entry resolution **Integration category:** Required for first integration -The SDK resolves entries locally after your app has fetched Contentful data and the SDK has selected -optimizations for the visitor. - -1. Fetch entries with one concrete Contentful locale. Do not pass all-locale payloads from - `locale=*` or all-locale SDK helpers into entry resolution. -2. Include linked entries deeply enough for `fields.nt_experiences`, the referenced optimization - entries, and `fields.nt_variants` to be present as resolved dictionaries. -3. Keep the app's Contentful locale aligned with SDK `locale` when rendered content and events need - to use the same locale. +The iOS SDK has no fetch-by-ID path, so your app always owns the Contentful Delivery API fetch. You +fetch the entry, hand it to the SDK, and the SDK resolves it locally against the selected +optimizations for the current visitor. + +`client.selectedOptimizations` (plural) is the SDK's current set of selected optimizations — one +selection per experience the visitor's profile matched, published on the client and updated from +Experience API responses. `resolveOptimizedEntry(baseline:selectedOptimizations:)` returns a +`ResolvedOptimizedEntry` — an SDK result type that wraps the resolved `entry`, the single +`selectedOptimization` (singular) that was applied to it, and an `optimizationContextId` identifying +the optimization context, the profile-and-selection state that produced the variant. Note the +one-letter difference: `selectedOptimizations` is the set you pass in (or the SDK resolves against), +while `selectedOptimization` is the one selection returned on the result. + +1. Fetch entries with one concrete Contentful locale. Do not pass all-locale payloads (`locale=*` or + all-locale helpers) into entry resolution — they fall back to baseline. +2. Include linked entries deeply enough to resolve the optimization links. `nt_experiences` (plural) + is the SDK-fixed link field the SDK reads on an optimized entry; it links that entry's + `nt_experience` (singular) experiences, and each experience links its `nt_variants` and + `nt_audience` entries. These are SDK-owned Optimization content-model names, not names you choose; + your fetch must `include` deeply enough to pull them back in one payload. `include: 10` is the + reference implementation's pattern. +3. Keep the app's Contentful locale aligned with SDK `locale` when rendered content and events must + use the same language. 4. Resolve entries during view, cell, or wrapper configuration. 5. Render `result.entry`. Use `result.selectedOptimization` and `result.optimizationContextId` only when building tracking payloads. @@ -331,6 +426,7 @@ optimizations for the visitor. **Follow this pattern:** ```swift +// contentfulEntryService is reader-owned: your app's CDA fetch and link resolution. let entry = try await contentfulEntryService.fetchEntry( id: entryId, include: 10, @@ -343,103 +439,104 @@ let result = client.resolveOptimizedEntry( selectedOptimizations: client.selectedOptimizations ) -// Always render result.entry; it falls back to the baseline entry when no -// selected optimization can be applied. +// Always render result.entry; it is the variant when one applies, or the +// baseline entry otherwise. contentView is reader-owned UI. contentView.configure(with: result.entry) ``` -`resolveOptimizedEntry(baseline:selectedOptimizations:)` is synchronous and fail-soft. It returns -the baseline entry when no selected optimization matches, when the entry is not optimized, when the -linked optimization data is missing, or when the selected variant is not present in the Contentful -payload. For deeper resolver mechanics, see -[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md). +`resolveOptimizedEntry` is synchronous and fail-soft: it never throws or breaks the UI. It returns +the baseline entry unchanged (with `selectedOptimization` and `optimizationContextId` nil) when the +client is not initialized, when the entry is not optimized, when no selected optimization matches, +when linked optimization data is missing, or when the selected variant is not present in the +payload. Passing `nil` for `selectedOptimizations` tells the resolver to use the SDK's current +selection state; passing an explicit snapshot resolves against exactly that (used for locked screens +in [Live updates and locked variants](#live-updates-and-locked-variants)). For deeper resolver +mechanics, see +[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). -### Screen, custom event, and entry tracking +### Screen and navigation tracking -**Integration category:** Common but policy-dependent +**Integration category:** Required for first integration -UIKit apps decide when a screen is visible, when a user interacted with a Contentful entry, and when -an entry has met the app's visibility threshold. +The quick start tracked one screen. Real UIKit navigation repeats lifecycle callbacks across modal, +tab, and navigation-controller transitions, so choose the method that matches the event you want. -#### Screen events +Use `trackCurrentScreen(name:properties:routeKey:)` for UIKit lifecycle and navigation tracking: it +deduplicates the current route in the SDK by `routeKey` (which defaults to `name`), so a repeat of +the same current screen is skipped and a blocked attempt is retried once consent allows. Use +`screen(name:properties:)` only for intentional one-off raw screen events, which carry no dedupe. -1. Emit screen events from `viewDidAppear(_:)` for screens that represent navigation destinations. +1. Emit from `viewDidAppear(_:)` so UIKit has completed the visible transition. 2. Use a stable screen name that maps to your analytics model. -3. Add properties only when the downstream analysis needs them. -4. Guard duplicate emissions when a UIKit lifecycle callback fires more often than the event model - expects. - -Use `trackCurrentScreen(name:properties:routeKey:)` for UIKit lifecycle and navigation tracking -because it deduplicates the current route. Use `screen(name:properties:)` only for intentional -one-off raw screen events. +3. Pass a stable `routeKey` when several instances of one destination should still count as the same + current screen, or when the default name-based key would collide. +4. Add `properties` only when the downstream analysis needs them. -**Copy this:** +**Adapt this to your use case:** ```swift override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - Task { @MainActor in - // Emit from viewDidAppear so UIKit has completed the visible transition. + // entryId is reader-owned: the value that identifies this destination. _ = try? await client.trackCurrentScreen( name: "ProductDetail", properties: ["entryId": entryId], - // Use a stable route key to prevent duplicate current-screen events - // when UIKit lifecycle callbacks repeat for the same destination. + // Stable route key prevents duplicate current-screen events when the + // lifecycle callback repeats for the same destination. routeKey: "product-detail-\(entryId)" ) } } ``` -#### Custom events +For shared tracking mechanics and event delivery, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). -Use custom events for business actions that are not tied to a Contentful entry replacement. +### Entry interaction tracking -**Copy this:** +**Integration category:** Common but policy-dependent -```swift -Task { @MainActor in - // This event is useful for local verification through eventStream or the - // iOS reference app event display. - _ = try? await client.track( - event: "Purchase Completed", - properties: ["sku": sku] - ) -} -``` +UIKit does not automatically infer when a user tapped a Contentful entry or when an entry met a +visibility threshold, so your app owns the geometry and the app decides whether these events are +allowed by its Analytics and privacy policy. Entry views deliver on the wire as `component` events; +entry taps as `component_click`. -#### Entry taps +**Entry taps.** Build a `TrackingMetadata` (an SDK helper type that derives +`componentId`/`experienceId`/`variantIndex` from an entry and its selected optimization) from the +resolution you already rendered, then pass its fields to a `TrackClickPayload` (an SDK payload type). +Building the metadata from the stored resolution — not by re-resolving at tap time — makes the tap +carry the same optimization context that produced the rendered variant. -1. Resolve the entry before rendering. -2. Store the most recent `ResolvedOptimizedEntry` produced during render or view configuration. -3. Build tracking metadata from the baseline entry and the selected optimization returned by the - stored resolution, not by re-resolving at tap time. -4. Call `client.trackClick(TrackClickPayload(...))` from a `UIControl` action or gesture recognizer. +1. Resolve and render the entry, and store the `ResolvedOptimizedEntry` you rendered from. +2. On tap, build `TrackingMetadata` from the stored baseline entry and its `selectedOptimization`. +3. Call `client.trackClick(TrackClickPayload(...))` from a `UIControl` action or gesture recognizer. For gesture recognizers, gate the dispatch to the completed gesture state instead of suppressing - later taps for the view lifetime. + later taps for the view's lifetime. **Adapt this to your use case:** ```swift +// Reader-owned: your view or cell stores the resolution it rendered from. private var latestBaselineEntry: [String: Any]? private var latestResolution: ResolvedOptimizedEntry? -func configure(entry: [String: Any]) { +func configure(with entry: [String: Any]) { + // entry is a reader-owned Contentful entry your app fetched. let result = client.resolveOptimizedEntry( baseline: entry, selectedOptimizations: client.selectedOptimizations ) - latestBaselineEntry = entry latestResolution = result - contentView.configure(with: result.entry) + contentView.configure(with: result.entry) // contentView is reader-owned UI. } -@objc private func primaryCTATapped() { +@objc private func primaryButtonTapped() { guard let entry = latestBaselineEntry, let result = latestResolution else { return } - // Use the same optimization context that produced the rendered variant. + // TrackingMetadata carries the optimization context that produced the + // rendered variant, so the tap matches what the visitor actually saw. let metadata = TrackingMetadata( entry: entry, optimizationContextId: result.optimizationContextId, @@ -457,11 +554,14 @@ func configure(entry: [String: Any]) { } ``` -#### Entry views - -UIKit does not automatically infer component visibility. Use app-owned scroll-view, table-view, or -collection-view geometry and either call `client.trackView(TrackViewPayload(...))` directly or use -`ViewTrackingController` to apply the SDK's visibility timing model. +**Entry views.** Feed app-owned scroll or layout geometry to a `ViewTrackingController` — the SDK's +imperative view-timing engine for UIKit — and it applies the same timing model and emits a +`TrackViewPayload` (an SDK payload type) through the client for you. The controller uses the default +model: an initial view event once the entry accumulates 2 seconds (`dwellTimeMs`) at 80% visibility +(`minVisibleRatio`), periodic duration updates every 5 seconds (`viewDurationUpdateIntervalMs`) while +visible, and a final duration event when visibility ends once at least one event has fired. It also +pauses on backgrounding and re-evaluates on foreground, and dedupes its own sticky views, so you only +own the geometry and the call site. **Follow this pattern:** @@ -471,10 +571,19 @@ final class OptimizedEntryView: UIView { private let entry: [String: Any] private weak var scrollView: UIScrollView? private var trackingController: ViewTrackingController? + private var offsetObservation: NSKeyValueObservation? + + // Call site: resolve the entry when the view is configured, then (re)build + // the controller for that resolution — the same place you render the entry. + func configure() { + let result = client.resolveOptimizedEntry(baseline: entry) + rebuildTracking(result: result) + // ...render result.entry with your own view code... + } + // Rebuild the controller whenever a newly resolved variant changes the + // tracking metadata, ending the previous visibility cycle first. private func rebuildTracking(result: ResolvedOptimizedEntry) { - // End the previous visibility cycle before replacing tracking metadata - // for a newly resolved variant. trackingController?.onDisappear() trackingController = ViewTrackingController( client: client, @@ -482,14 +591,20 @@ final class OptimizedEntryView: UIView { optimizationContextId: result.optimizationContextId, selectedOptimization: result.selectedOptimization ) + emitVisibility() + // Call site: feed geometry on every scroll change so the controller can + // run its timing model. Also call emitVisibility() on layout changes. + offsetObservation = scrollView?.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in + Task { @MainActor in self?.emitVisibility() } + } } + // Reader-owned geometry: your app computes the element's position and feeds + // it to the controller, which owns timing, consent checks, and duplicate + // duration-event prevention for the cycle. private func emitVisibility() { guard let controller = trackingController, let scrollView else { return } - let frameInScroll = convert(bounds, to: scrollView) - // UIKit owns geometry; the SDK controller owns timing, consent checks, - // and duplicate duration-event prevention for this visibility cycle. controller.updateVisibility( elementY: frameInScroll.minY, elementHeight: bounds.height, @@ -500,10 +615,8 @@ final class OptimizedEntryView: UIView { } ``` -`ViewTrackingController` uses the same default model documented for the iOS SDK: an initial view -event after 2 seconds at 80% visibility, periodic duration updates every 5 seconds while visible, -and a final duration update after the entry leaves view once a view event has emitted. For shared -tracking mechanics and event delivery, see +To opt an entry out of view or tap tracking, do not install its controller or gesture recognizer. +For shared tracking mechanics, see [iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). ### Identity, profile continuity, and reset @@ -511,21 +624,23 @@ tracking mechanics and event delivery, see **Integration category:** Common but policy-dependent Identity policy belongs to the application. The SDK can identify a visitor, update selected -optimizations from Experience API responses, persist profile-continuity state when allowed, and -reset SDK-managed profile state. +optimizations and `changes` (the inline field and flag values the Experience API returned for the +visitor) from Experience API responses, persist profile-continuity state when allowed, and reset +SDK-managed profile state, but it does not decide when a user becomes known or how account data is +governed. 1. Call `identify(userId:traits:)` after sign-in or when the app has a stable application user ID. -2. Wait for SDK state, rendered content, or app-owned loading state before assuming the profile has - affected visible entries. -3. Call `reset()` when the app's logout or privacy flow must clear SDK-managed profile, - selected-optimization, change, and anonymous ID state. -4. Preserve or clear app-owned user identifiers according to your account and privacy policy. The - SDK does not clear your application storage. +2. Wait for SDK state or rendered content before assuming the profile has affected visible entries. +3. Call `reset()` on logout, account switch, or a privacy flow that must clear SDK-managed profile, + selected-optimization, change, and anonymous-ID state. +4. Preserve or clear app-owned user identifiers according to your account and privacy policy; the SDK + does not clear your application storage. -**Copy this:** +**Adapt this to your use case:** ```swift Task { @MainActor in + // identify links the app-owned user ID to the current mobile profile. _ = try? await client.identify( userId: user.id, traits: ["plan": user.plan] @@ -536,31 +651,129 @@ Task { @MainActor in **Copy this:** ```swift +// reset() clears profile continuity but preserves consent state. client.reset() ``` -When durable profile-continuity persistence is allowed, SDK state from an Experience response is -published after the corresponding `UserDefaults` write settles. In tests and relaunch flows, wait -for SDK-derived UI or state instead of adding arbitrary storage delays. +`reset()` clears profile continuity (profile, changes, selected optimizations, the anonymous ID, the +current-screen dedupe tracker, and sticky-view keys) but **preserves consent state**, and it no-ops +before initialization. When persistence consent is allowed, the SDK writes continuity to +`UserDefaults` and publishes SDK state from an Experience response after that write settles. In tests +and relaunch flows, wait for SDK-derived UI or state instead of adding arbitrary storage delays. The +SDK persists to `UserDefaults` under the `com.contentful.optimization.` prefix, not to cookies, and +provides no built-in cross-platform identity handoff — implement any web, server, or account +continuity in application code. For the identifier model, see +[Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md#revocation-and-profile-cleanup). ## Optional integrations +### Custom events and analytics diagnostics + +**Integration category:** Optional + +Use custom events for business actions that are not tied to a Contentful entry swap, and the event +streams for local diagnostics or app-owned analytics forwarding. + +1. Call `track(event:properties:)` for a business event. +2. Subscribe to `eventStream` for accepted events; subscribe to `blockedEventStream` (or configure + `onEventBlocked` at startup) for events stopped by consent or the allow-list. +3. Subscribe before the events you want to observe fire — `eventStream` is a passthrough publisher + that does not replay earlier events to late subscribers. + +**Copy this:** + +```swift +Task { @MainActor in + // A custom business event, not tied to a Contentful entry swap. + _ = try? await client.track(event: "Purchase Completed", properties: ["sku": "ABC-123"]) +} +``` + +**Adapt this to your use case:** + +```swift +// eventStream is a passthrough publisher with no replay: subscribe before the +// events you want to observe fire, or you miss the earlier ones. +client.eventStream + .sink { event in analyticsDebugStore.append(event) } + .store(in: &cancellables) + +// blockedEventStream surfaces events stopped by consent or the allow-list — +// the diagnostic for a missing event during integration. +client.blockedEventStream + .sink { blocked in print("blocked \(blocked.method): \(blocked.reason)") } + .store(in: &cancellables) +``` + +When forwarding SDK events to third-party destinations, apply the same app-owned consent policy, +deduplication, and data-minimization rules that govern the destination. For destination mapping, +consent, identity, dedupe, and governance guidance, see +[Forwarding Optimization SDK context to analytics and tag-management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). + +### Custom Flags and MergeTag rendering + +**Integration category:** Optional + +Use Custom Flags when your Optimization data includes profile-backed feature values, and merge tags +when it includes profile-driven text substitutions in Rich Text. Both read from SDK state +separately from entry-variant resolution. + +1. Read a flag once with `getFlag(_:)` when a synchronous value is enough. +2. Subscribe with `flagPublisher(_:)` when the UI must update as flag values change. +3. Resolve merge tags with `getMergeTagValue(mergeTagEntry:)` from your app-owned Rich Text renderer. + +**Copy this:** + +```swift +// Non-reactive one-shot read; returns nil before init or when unresolved. +let flagValue = client.getFlag("show-promo") +``` + +**Adapt this to your use case:** + +```swift +// Subscribing registers an observeFlag subscription. A flag subscription emits +// a component flag-view event (an analytics exposure) when consent and profile +// allow, so treat it as tracked exposure, not a free read, and govern it like +// any other event. +client.flagPublisher("show-promo") + .receive(on: RunLoop.main) + .sink { [weak self] value in self?.applyPromoFlag(value) } + .store(in: &cancellables) +``` + +`nt_mergetag` is the SDK-fixed Optimization content type for a merge tag — a profile-driven text +substitution embedded inline in Rich Text; it is not a name you choose. Your app owns extracting the +embedded `nt_mergetag` entry from the Rich Text node before calling the SDK, which resolves the +selector against the current profile and returns the resolved string or `nil`. + +**Follow this pattern:** + +```swift +// mergeTagEntry is reader-owned: the expanded embedded-entry-inline node's +// data.target you extracted from Rich Text. +let resolved = client.getMergeTagValue(mergeTagEntry: mergeTagEntry) +// resolved is String?; fall back to the merge tag's configured value on nil. +``` + +For the deeper data model, see +[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#merge-tags-and-localized-profile-values). + ### Live updates and locked variants **Integration category:** Optional UIKit apps choose whether optimized content updates live or locks to the first selected variant for -the screen. - -1. Use locked variants when content must not change while a visitor is reading a screen. -2. After the first screen or identity state that the screen locks to has resolved, capture - `client.selectedOptimizations ?? []` and set a separate `hasLockedOptimizations` flag. -3. Pass that explicit snapshot to every `resolveOptimizedEntry` call on the locked screen. Do not - pass `nil` for locked screens because `nil` tells the resolver to use current SDK state. -4. Use live updates when a debug surface, preview flow, or dynamic screen needs to redraw after - profile or override changes. -5. Subscribe to `client.$selectedOptimizations` and redraw affected views for live behavior. -6. Treat `client.isPreviewPanelOpen` as a reason to redraw live while previewing. +the screen. There is no automatic locking in UIKit; you pick the policy by how you pass +`selectedOptimizations` and whether you redraw on state changes. + +1. To lock a screen, capture `client.selectedOptimizations ?? []` once after the first resolution and + pass that explicit snapshot to every `resolveOptimizedEntry` call on the screen. Do not pass `nil` + for locked screens, because `nil` tells the resolver to use current SDK state. +2. To update live, pass `nil` (or the current `client.selectedOptimizations`) and subscribe to + `client.$selectedOptimizations` to redraw affected views when selections change. +3. Treat `client.isPreviewPanelOpen` as a reason to redraw live: an open preview panel forces live + updates so applied overrides appear immediately. **Adapt this to your use case:** @@ -570,28 +783,18 @@ private var hasLockedOptimizations = false func lockVariantsForScreen() { guard !hasLockedOptimizations else { return } - - // Call this after the screen or identity event this screen locks to - // has resolved. - // Capture an explicit screen-level snapshot. Empty array means lock to no - // selected variants; nil asks the resolver to use current SDK state. + // Capture an explicit snapshot after the screen's first resolution. + // Empty array locks to no selections; nil would ask for current SDK state. lockedOptimizations = client.selectedOptimizations ?? [] hasLockedOptimizations = true } -func handleScreenEventResolved(entry: [String: Any]) { - lockVariantsForScreen() - render(entry: entry) -} - func render(entry: [String: Any]) { guard hasLockedOptimizations else { return } - let result = client.resolveOptimizedEntry( baseline: entry, selectedOptimizations: lockedOptimizations ) - contentView.configure(with: result.entry) } ``` @@ -600,12 +803,11 @@ func render(entry: [String: Any]) { ```swift client.$selectedOptimizations + // @Published fires in willSet, so hop to the next run-loop turn to read the + // committed selections before re-resolving. .receive(on: RunLoop.main) .sink { [weak self] _ in - guard self?.client.isPreviewPanelOpen == true || self?.liveUpdates == true else { - return - } - + guard self?.client.isPreviewPanelOpen == true || self?.liveUpdates == true else { return } self?.reloadVisibleContent() } .store(in: &cancellables) @@ -618,16 +820,17 @@ For the precedence between live updates, locked variants, and preview-panel stat **Integration category:** Optional -`PreviewPanelViewController` hosts the SDK preview panel from a UIKit navigation stack. Gate it -behind a debug or internal-build condition so production users cannot open local audience and -variant overrides. - -1. Create or reuse a `PreviewContentfulClient` that can fetch `nt_audience` and `nt_experience` - entries. -2. Add the floating button to a host controller, or present `PreviewPanelViewController` yourself. -3. Pass the same initialized `OptimizationClient` used by the rest of the app. -4. Pass a `PreviewContentfulClient` when the panel needs audience and experience override controls. -5. Keep the preview panel out of public production builds unless your release policy explicitly +`PreviewPanelViewController` hosts the SDK preview panel from a UIKit view controller. Gate it behind +a debug or internal-build condition so production users cannot open local audience and variant +overrides. + +1. Create a `PreviewContentfulClient` (the built-in `ContentfulHTTPPreviewClient` fetches + `nt_audience` and `nt_experience` definitions) for the space and environment holding your + Optimization entries. +2. Add the floating button to a host controller with `addFloatingButton(to:client:contentfulClient:)`, + passing the same initialized `OptimizationClient` the rest of the app uses so overrides affect the + same resolver and event state. +3. Keep the preview panel out of public production builds unless your release policy explicitly allows it for an internal audience. **Adapt this to your use case:** @@ -642,91 +845,45 @@ let previewContentfulClient = ContentfulHTTPPreviewClient( PreviewPanelViewController.addFloatingButton( to: homeViewController, - // Pass the app-owned SDK instance so preview overrides affect the same - // resolver and event state used by the screen. + // Pass the app-owned client so overrides affect the same resolver and state. client: client, contentfulClient: previewContentfulClient ) #endif ``` -Passing `contentfulClient` is optional only for profile and debug state. Without it, the panel can -still open, but no audience or experience definitions are loaded: the audience section is empty, -audience and variant override controls are unavailable, and existing override summaries can fall -back to identifiers. - -### Custom Flags and debug event streams - -**Integration category:** Optional - -Use Custom Flags when your Contentful optimization data includes inline variable changes rather than -entry replacement. Use event streams for local diagnostics, app-owned debug views, or governed -analytics forwarding. - -1. Read a flag once with `getFlag(_:)` when a synchronous value is enough. -2. Subscribe with `flagPublisher(_:)` when the UI needs to update as the SDK receives changed flag - values. -3. Subscribe to `eventStream` only for diagnostics or application-owned forwarding that has passed - consent and destination governance review. -4. Subscribe to `blockedEventStream`, or configure `onEventBlocked` at startup, to debug consent or - pre-consent allow-list blocks during integration. - -**Copy this:** - -```swift -let flagValue = client.getFlag("boolean") -``` - -**Adapt this to your use case:** - -```swift -client.flagPublisher("boolean") - .receive(on: RunLoop.main) - .sink { [weak self] value in - self?.applyBooleanFlag(value) - } - .store(in: &cancellables) -``` - -**Adapt this to your use case:** - -```swift -client.eventStream - .sink { event in - analyticsDebugStore.append(event) - } - .store(in: &cancellables) -``` - -When forwarding SDK events to third-party destinations, apply the same app-owned consent policy, -deduplication, and data-minimization rules that govern the destination. - -Use `EventEmissionResult`, queue callbacks, logs, and app-owned diagnostics for other guard or -suppression cases. +Passing `contentfulClient` is what loads audience and experience definitions by name. Without it the +panel can still open, but no definitions are loaded: the audience section is empty, audience and +variant override controls are unavailable, and existing override summaries can fall back to raw +identifiers. ### Runtime locale changes **Integration category:** Optional -Use this section when the app can change language or locale after SDK startup. The SDK locale and -the Contentful CDA locale are separate inputs, even when they usually carry the same value. +Use this section when the app can change language or locale after SDK startup. The SDK locale and the +Contentful CDA locale are separate inputs, even when they usually carry the same value. -1. Derive the next app locale from the app's navigation, i18n, account, or settings layer. -2. Call `setLocale(_:)` to update the SDK Experience/event locale. -3. Refetch Contentful entries with the same app locale when rendered content needs to change. +1. Derive the next app locale from your navigation, i18n, account, or settings layer. +2. Call `setLocale(_:)` to update the SDK Experience and event locale. It updates the SDK locale + only — it does not refetch Contentful entries or refresh profile state — and it `throws` before + init or on an invalid locale. +3. Refetch Contentful entries with the same locale and re-resolve visible entries once the localized + payload and SDK state are both ready. 4. Invalidate app-owned content caches using locale-aware cache keys. -5. Re-resolve visible entries after the localized Contentful payload and SDK state are both ready. **Adapt this to your use case:** ```swift let nextLocale = "de-DE" +// Updates the SDK Experience/event locale only; throws on an invalid locale. try client.setLocale(nextLocale) + +// Reader-owned refetch in the same locale, then re-resolve and redraw. entries = try await contentfulEntryService.fetchEntries( ids: entryIds, include: 10, - // Refetch CDA content in the same locale used for SDK event context. locale: nextLocale ) reloadVisibleContent() @@ -743,25 +900,27 @@ For the full locale model, see The iOS SDK monitors network reachability, queues events while offline, flushes when connectivity returns, and flushes as the app moves toward the background. No setup is required for the default -offline path. +offline path: `NWPathMonitor` drives the SDK online state and flushes on reconnect, and the app +lifecycle handler flushes on `willResignActive`. -1. Add `QueuePolicy` only when production telemetry needs queue limits or queue lifecycle callbacks. +1. Add `QueuePolicy` only when production telemetry needs queue limits or lifecycle callbacks. The + offline Experience queue holds up to 100 events by default (tunable via + `QueuePolicy.offlineMaxEvents`); queues are in-memory only and do not survive process death. 2. Use queue callbacks for operational diagnostics, not for resending blocked or dropped events. -3. Keep Contentful entry caching in the application layer. The SDK does not cache CDA responses for +3. Keep Contentful entry caching in the application layer — the SDK does not cache CDA responses for UIKit rendering. -4. Treat hybrid server-client continuity as not applicable to a native UIKit-only app. Use SDK - profile continuity plus app-owned account state instead. -5. Call `flush()` only for deliberate release, test, or lifecycle flows. The SDK already flushes on - background and reconnect events. +4. Call `flush()` only for deliberate release, test, or lifecycle flows; the SDK already flushes on + background and reconnect. **Adapt this to your use case:** ```swift let config = OptimizationConfig( - clientId: "your-client-id", + clientId: "your-optimization-client-id", queuePolicy: QueuePolicy( offlineMaxEvents: 500, onOfflineDrop: { event in + // event is a QueueEvent with a type and a context dictionary. diagnostics.record("optimization-offline-drop", context: event.context) }, onFlushFailure: { event in @@ -778,44 +937,53 @@ let config = OptimizationConfig( Before release, verify the UIKit integration against these checks: -- **Credentials and runtime configuration** - The app uses the intended Optimization client ID, +- **Credentials and runtime configuration** — The app uses the intended Optimization client ID, Contentful environment, SDK `locale`, and CDA locale. Non-default API base URLs and `.debug` logging are absent from production builds unless explicitly approved. -- **Consent behavior** - Default-on startup, CMP wiring, refusal, withdrawal, split event and +- **Consent behavior** — Startup consent, CMP wiring, refusal, withdrawal, split event and persistence consent, and `reset()` behavior match the app's legal and privacy requirements. -- **Event delivery** - Screen, custom, tap, view, identify, and flag-view events appear when allowed +- **Event delivery** — Screen, custom, tap, view, identify, and flag-view events appear when allowed and are blocked or omitted when policy denies them. -- **Content fallback behavior** - Baseline entries render when selected optimizations are missing, - unresolved links are returned, variants are out of range, or the user is not qualified. -- **Duplicate tracking prevention** - UIKit lifecycle hooks, reusable cells, gesture recognizers, +- **Content fallback behavior** — Baseline entries render when selected optimizations are missing, + unresolved links are returned, variants are out of range, or the visitor is not qualified. +- **Duplicate tracking prevention** — UIKit lifecycle hooks, reusable cells, gesture recognizers, and visibility observers do not emit duplicate screen, tap, or view events for one intended interaction or visibility cycle. -- **Privacy and governance** - Preview-panel access, event forwarding, profile IDs, user traits, +- **Privacy and governance** — Preview-panel access, event forwarding, profile IDs, user traits, app-owned caches, and diagnostics follow the app's data-minimization and retention policy. -- **Local validation path** - Compare the app against the iOS reference implementation, or run the - UIKit XCUITest flow with the mock server when changing native integration behavior: - `APP_SHELL=uikit ./scripts/run-e2e.sh` from `implementations/ios-sdk/`. +- **Local validation path** — Compare your integration against the iOS reference implementation. The + repository's maintainers validate UIKit behavior with an XCUITest suite driven from + `implementations/ios-sdk/`; that runner is a maintainer command, not an app command. + + **Reference excerpt:** + + ```sh + # From implementations/ios-sdk/ in the optimization monorepo — a maintainer + # command that builds the JS bridge, starts the mock server, and runs XCUITest. + APP_SHELL=uikit ./scripts/run-e2e.sh + ``` ## Troubleshooting -- **Optimized entries always render the baseline** - Confirm the app fetched a single-locale entry, +- **Optimized entries always render the baseline** — Confirm the app fetched a single-locale entry, requested enough `include` depth for `nt_experiences` and `nt_variants`, initialized the client, and has non-empty `client.selectedOptimizations` for the visitor. -- **Tap or view events do not appear** - Check consent, `allowedEventTypes`, the component ID from +- **Tap or view events do not appear** — Check consent, `allowedEventTypes`, the `componentId` from `TrackingMetadata`, UIKit gesture wiring, and whether the view reached the configured visibility threshold long enough to emit. -- **Screen events appear more than once** - Review `viewDidAppear(_:)` calls for modal, tab, and - navigation-controller transitions, then add route-key or app-level guards that match your - analytics model. -- **Preview panel opens but shows identifiers** - Pass a `PreviewContentfulClient` that can fetch - audience and experience entries from the correct space and environment. -- **Identified variants disappear after relaunch** - Verify persistence consent is `true`, wait for +- **Screen events appear more than once** — Review `viewDidAppear(_:)` calls for modal, tab, and + navigation-controller transitions, and prefer `trackCurrentScreen` with a stable `routeKey` over + raw `screen` for lifecycle tracking. +- **Preview panel opens but shows identifiers** — Pass a `PreviewContentfulClient` that can fetch + `nt_audience` and `nt_experience` entries from the correct space and environment. +- **Identified variants disappear after relaunch** — Verify persistence consent is `true`, wait for SDK-published profile or selected-optimization state before terminating tests, and confirm logout or withdrawal flows are not calling `reset()`. ## Reference implementations to compare against -- [iOS reference implementation](../../implementations/ios-sdk/README.md) - Demonstrates SwiftUI and - UIKit shells that exercise the native iOS bridge, default accepted consent, single-locale CDA - fetching, entry resolution, interaction tracking, screen tracking, Custom Flags, offline queueing, - and preview-panel overrides against the same mock API. +- [iOS reference implementation](../../implementations/ios-sdk/README.md) — Maintained SwiftUI and + UIKit shells that exercise the native iOS bridge against the shared mock API: accepted-consent + startup, single-locale CDA fetching, entry resolution, screen tracking, interaction tracking, + Custom Flags and merge tags, live updates, offline queueing, and preview-panel overrides. Use it as + the comparison and validation target for UIKit integration behavior. diff --git a/documentation/internal/sdk-knowledge/native/ios.md b/documentation/internal/sdk-knowledge/native/ios.md new file mode 100644 index 00000000..9f496c37 --- /dev/null +++ b/documentation/internal/sdk-knowledge/native/ios.md @@ -0,0 +1,312 @@ +# iOS (`ContentfulOptimization` Swift Package) — SDK knowledge + + + +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. + +Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) +and [`../shared/concepts.md`](../shared/concepts.md). The concepts they capture — consent/persistence +axes, baseline fallback, entry-source managed-vs-manual, live updates, single-locale entry contract, +experience-response payload — are SDK-neutral and apply to the whole suite via the shared `core-sdk`. + +This is a **beta** native Swift Package, not a TypeScript package: its source has a `Package.swift` +and no `package.json`/`src/`, so `knowledge:check` has no `ios` SDK key and cannot resolve Swift +`#symbol` pointers. Swift-specific facts therefore use extern-prefixed pointers whose free text names +the exact Swift file and symbol; behavior that actually executes in the shared JS layer uses the real +resolvable keys — the js-bridge adapter, the shared core/api packages, concept docs, the reference +implementation, and cross-links into this base. The iOS SDK runs the **same `CoreStateful` runtime as +the web/React-Native SDKs** inside a per-client JavaScriptCore context, bridged by the TypeScript +adapter in `packages/universal/optimization-js-bridge/src/index.ts`; most +optimization/consent/event/queue behavior parallels [React Native](./react-native.md). Swift owns +native concerns: persistence (`UserDefaults`), networking (`URLSession`/`NWPathMonitor`), app +lifecycle, SwiftUI views, and preview-panel UI. Swift source root: +`packages/ios/ContentfulOptimization/Sources/ContentfulOptimization`. + +## Package & entry points + +Single module: `import ContentfulOptimization`. There is one SDK; both guides consume it — SwiftUI +apps mostly use the view surface, UIKit apps mostly use the imperative `OptimizationClient` surface. + +| Import path (`ContentfulOptimization`) | Public symbol or purpose | source | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| SwiftUI surface | `OptimizationRoot`, `OptimizedEntry`, `OptimizationScrollView`, `.trackScreen(name:)` / `ScreenTrackingModifier`, `TrackingConfig`, `ScrollContext` | extern:SwiftUI views/modifiers — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift#OptimizationRoot; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift#trackScreen | +| Imperative client | `OptimizationClient` (`@MainActor` `ObservableObject`); `EventEmissionResult` | extern:OptimizationClient is a @MainActor ObservableObject facade wrapping the JS bridge — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient | +| Config | `OptimizationConfig`, `OptimizationApiConfig`, `StorageDefaults`, `OptimizationLogLevel`, `QueuePolicy`, `QueueFlushPolicy`, `QueueEvent`/`QueueEventType`, `BlockedEvent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#StorageDefaults | +| Event payloads | `IdentifyPayload`, `PageEventPayload`, `ScreenEventPayload`, `TrackEventPayload`, `TrackViewPayload`, `TrackClickPayload` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/EventPayloads.swift#ScreenEventPayload; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/TrackViewPayload.swift#TrackViewPayload | +| State / result types | `OptimizationState`, `ResolvedOptimizedEntry`, `PreviewState` (+ DTOs), `JSONValue`, `OptimizationError` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationState.swift#OptimizationState; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationError.swift#OptimizationError | +| Tracking (imperative) | `ViewTrackingController`, `TrackingMetadata` | extern:ViewTrackingController is a @MainActor imperative view-timing engine for UIKit — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController | +| Preview panel | `PreviewPanelOverlay` (SwiftUI), `PreviewPanelViewController` (UIKit), `PreviewPanelConfig`, `PreviewContentfulClient` / `ContentfulHTTPPreviewClient`, `PreviewPanelContent` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelViewController.swift#PreviewPanelViewController; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewContentfulClient.swift#ContentfulHTTPPreviewClient | + +- SPM target links `JavaScriptCore` for consuming apps and copies `optimization-ios-bridge.umd.js` + as a package resource; platforms are iOS 15+ / macOS 12+. source: extern:Package.swift links JavaScriptCore and copies the UMD bundle resource, iOS 15/macOS 12 — packages/ios/ContentfulOptimization/Package.swift +- Distribution: the Swift Package is published to a separate repo, + `https://github.com/contentful/optimization.swift` (consumers add by URL `from: "x.y.z"`); the UMD + bundle is built on demand (gitignored in the monorepo) from `optimization-js-bridge`, not + hand-edited. source: extern:published to github.com/contentful/optimization.swift, UMD built from optimization-js-bridge — packages/ios/README.md; optimization-js-bridge#index.ts#initialize +- Every optimization/consent/event/queue call ultimately runs `CoreStateful` through the bridge's + `globalThis.__bridge`; the Swift `OptimizationClient` is a thin native facade over it. + source: optimization-js-bridge#index.ts#Bridge; core-sdk#CoreStateful.ts#CoreStatefulConfig + +## Setup / factory + +- Two usage modes for the one client. SwiftUI: `OptimizationRoot(config:)` owns the client + (`@StateObject`), calls `initialize(config:)` in a `.task`, injects it via `@EnvironmentObject` + plus a `TrackingConfig` environment value, and renders a `ProgressView()` until + `client.isInitialized`. UIKit: the app creates `OptimizationClient()` and calls + `initialize(config:)` from scene/app startup, holding it for the scene/app lifetime and passing it + into view controllers manually. source: extern:OptimizationRoot @StateObject + .task initialize + ProgressView gate — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift#OptimizationRoot; impl:ios-sdk#swiftui/App.swift; impl:ios-sdk#uikit/SceneDelegate.swift +- `initialize(config:)` is **synchronous and `throws`** (not `async`) — it loads the UMD bundle and + runs bridge init inline on the `@MainActor`, so it blocks the main actor briefly at startup. The + doc-comment example showing `try await` is misleading; the reference apps call `try?` / + `try` without `await`. source: extern:initialize(config:) is synchronous throws, not async — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; impl:ios-sdk#uikit/SceneDelegate.swift +- Startup state resolution order in `initialize`: (1) `store.loadConsentState()` reads persisted + consent from `UserDefaults`; (2) `resolveStatefulDefaults(configured:persisted:)` merges configured + over persisted; (3) profile-continuity is loaded from `UserDefaults` **only** when the resolved + `persistenceConsent == true` (`canLoadPersistedContinuity`), otherwise continuity is cleared when + it resolves to `false`; (4) the merged `defaults` and a separate `anonymousId` are serialized into + the bridge config. source: extern:initialize resolves consent → StatefulPolicy defaults → conditional profile-continuity load — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; extern:resolveStatefulDefaults + canLoadPersistedContinuity — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/StatefulPolicy.swift#resolveStatefulDefaults +- `OptimizationConfig`: `clientId` required; `environment` has a **Swift-side default `"main"`** in + the initializer (unlike the JS SDKs, which fall back to the api-client `DEFAULT_ENVIRONMENT`); + `logLevel` default `.error`; `locale`, `api` (`experienceBaseUrl`/`insightsBaseUrl`/ + `enabledFeatures`/`preflight`), `allowedEventTypes`, `queuePolicy`, `defaults: StorageDefaults`, + `onEventBlocked` all optional. source: extern:environment default "main", logLevel default .error, clientId required — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig +- `config.toJSON()` serializes to the bridge `BridgeConfig` shape, omitting nil URLs and empty + sub-dicts; the bridge maps it into `CoreStatefulConfig` via `resolveStatefulDefaults`, defaulting + `allowedEventTypes` to `DEFAULT_NATIVE_ALLOWED_EVENT_TYPES` and installing `queuePolicy` callbacks + that push back through `__nativeOnQueueEvent`. source: extern:OptimizationConfig.toJSON → BridgeConfig — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; optimization-js-bridge#index.ts#BridgeConfig; optimization-js-bridge#index.ts#initialize +- `StorageDefaults` are startup defaults, not one-time seeds: configured values take precedence over + persisted `UserDefaults` values every launch, so a configured `consent`/`persistenceConsent` can + replace a stored choice. Apps that persist user choices leave these unset and call `consent(...)` + from resolved app policy instead. source: extern:StorageDefaults precedence resolved in StatefulPolicy — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/StatefulPolicy.swift#resolveStatefulDefaults; concept:ios-sdk-runtime-and-interaction-mechanics +- Locale is normalized before init: trims, maps `_`→`-`, and rejects empty / `*` / `und` / non + BCP-47-shaped values; an explicit invalid `locale` (config or `setLocale`) **throws** a + `configError` rather than being silently dropped. source: extern:normalizeLocale + normalizeExplicitLocale throw on invalid — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; concept:locale-handling-in-the-optimization-sdk-suite + +## Components & hooks + +For iOS these are SwiftUI views/modifiers plus the imperative `OptimizationClient` API. Read exact +signatures from the Swift types; below is a navigation index with behavioral facts as sourced bullets. + +| Name | Kind | source | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `OptimizationRoot` | SwiftUI root view | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift#OptimizationRoot | +| `OptimizedEntry` | SwiftUI view (render closure) | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry | +| `OptimizationScrollView` | SwiftUI scroll wrapper providing `ScrollContext` | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationScrollView.swift#OptimizationScrollView | +| `.trackScreen(name:)` / `ScreenTrackingModifier` | SwiftUI modifier | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift#trackScreen | +| `ViewTrackingController` | imperative view-timing engine (UIKit) | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController | +| `TrackingMetadata` | derives `componentId`/`experienceId`/`variantIndex`/`sticky` from entry+selection | extern:TrackingMetadata reads sys.id and selectedOptimization fields — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#TrackingMetadata | +| `PreviewPanelOverlay` | SwiftUI FAB + sheet | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelOverlay.swift#PreviewPanelOverlay | +| `PreviewPanelViewController` / `.addFloatingButton(to:...)` | UIKit preview host | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelViewController.swift#PreviewPanelViewController | +| `OptimizationClient` event/consent/resolve/preview API | imperative facade | extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient | + +- `OptimizedEntry` detects an optimized entry by the presence of `fields.nt_experiences`; only + optimized entries call `resolveOptimizedEntry`, non-optimized entries pass through as baseline. It + wraps the render closure's output with `ViewTrackingModifier` + `TapTrackingModifier` and exposes + itself as an accessibility container (`children: .contain`) so nested identifiers stay queryable. + source: extern:OptimizedEntry.isOptimized checks fields.nt_experiences and wraps with view/tap modifiers — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry +- `OptimizedEntry` tap-enable resolution: `trackTaps == false` disables; an explicit `trackTaps` or a + non-nil `onTap` enables; otherwise the root `TrackingConfig.trackTaps` default applies. View + tracking uses per-entry `trackViews ?? trackingConfig.trackViews`. Both default enabled at the + root. source: extern:OptimizedEntry tapsEnabled/viewsEnabled resolution — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Environment/TrackingEnvironment.swift#TrackingConfig +- `OptimizationScrollView` publishes a `ScrollContext { scrollY, viewportHeight }` on the SwiftUI + environment under the named coordinate space `"optimization-scroll"`; descendant `OptimizedEntry` + view-tracking reads it. Without an enclosing scroll view, tracking uses + `ViewTrackingController.fallbackViewportHeight` (`UIScreen.main.bounds.height`, or the main + `NSScreen` on macOS) and assumes `scrollY = 0`. source: extern:OptimizationScrollView provides ScrollContext via environment — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationScrollView.swift#OptimizationScrollView; extern:fallbackViewportHeight = UIScreen.main.bounds.height — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController +- `ViewTrackingController` is the imperative equivalent for UIKit (there is no automatic component + visibility tracking in UIKit): the app feeds `updateVisibility(elementY:elementHeight:scrollY: +viewportHeight:)` from its own scroll/layout callbacks and the controller applies the same timing + model and emits `TrackViewPayload` via the client. source: extern:ViewTrackingController.updateVisibility drives the timing model for UIKit — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController; concept:ios-sdk-runtime-and-interaction-mechanics +- The `OptimizationClient` API splits by call shape: **async `throws`** for events + (`identify`/`page`/`screen`/`track`/`trackCurrentScreen`/`flush`/`trackView`/`trackClick`, each + returning `EventEmissionResult` except the void ones), and **synchronous** for + `consent`/`reset`/`setOnline`/`resolveOptimizedEntry`/`getMergeTagValue`/`getFlag`/`getProfile`/ + `getState`/`setLocale` and the preview-override methods. Reactive surfaces are `@Published` + properties plus `eventStream`/`blockedEventStream`/`flagPublisher(_:)` Combine publishers. + source: extern:OptimizationClient async event methods vs sync reads + Combine publishers — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient + +## Render / entry resolution + +- `resolveOptimizedEntry(baseline:selectedOptimizations:)` is **synchronous and fail-soft**: it + serializes the baseline dict to JSON, calls the bridge, and returns a `ResolvedOptimizedEntry`. If + the client is not initialized, a serialization error occurs, or the bridge result cannot be parsed, + it returns the baseline entry unchanged (with `selectedOptimization`/`optimizationContextId` nil) + and logs a warning — it never throws or breaks the UI. source: extern:resolveOptimizedEntry synchronous, returns baseline on any failure — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; core-sdk#CoreBase.ts#resolveOptimizedEntry +- `selectedOptimizations` argument semantics: passing `nil` **omits the arg to the bridge**, so the + bridge resolves against the SDK's current selection state; passing an explicit `[[String: Any]]` + snapshot resolves against exactly that (used for locked UIKit screens). source: extern:resolveOptimizedEntry omits selectedOptimizations arg when nil — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; optimization-js-bridge#index.ts#Bridge +- SwiftUI `OptimizedEntry` locks to the first resolved variant by default: it snapshots + `selectedOptimizations` into `lockedOptimizations` on the first non-nil value when not live and not + yet locked, and resolves against the locked value thereafter. `shouldLiveUpdate` precedence: + preview panel open (`client.isPreviewPanelOpen`) forces live → per-entry `liveUpdates` → root + `TrackingConfig.liveUpdates` → default locked. When the panel closes, a locked entry snapshots the + current selections so applied overrides persist. source: extern:OptimizedEntry variant locking + shouldLiveUpdate precedence + panel-close snapshot — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry; kb:shared/concepts.md +- Single-locale CDA contract and include-depth requirement are shared and app-owned: fetch with one + concrete locale and `include` deep enough to resolve `nt_experiences` → `nt_experience` → + `nt_variants`/`nt_audience` (`nt_config` is a JSON field, not a link). All-locale payloads fall + back to baseline. The iOS SDK does not fetch CDA entries itself (except the preview panel's own + definition fetch); the app passes fetched entries to `OptimizedEntry`/`resolveOptimizedEntry`. + source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver; api-schemas#contentful/OptimizedEntry.ts#OptimizedEntryFields; concept:entry-personalization-and-variant-resolution; kb:shared/concepts.md +- Merge tags resolve separately from variant swap: `getMergeTagValue(mergeTagEntry:)` passes the + expanded inline `nt_mergetag` entry to the bridge, which reads the selector against the current + profile and returns the resolved string or `nil` (fallback). The app owns extracting the embedded + entry from Rich Text before calling it. source: extern:getMergeTagValue passes the mergetag entry to the bridge — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; core-sdk#resolvers/MergeTagValueResolver.ts#resolve; kb:shared/concepts.md + +## Identifier ownership + +| Identifier | Owner | Notes | source | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `com.contentful.optimization.consent`, `…persistenceConsent` (`UserDefaults`, suite `com.contentful.optimization`) | SDK | Consent state, always persisted on state change. Stored as the strings `"accepted"`/`"denied"` (not booleans) via `ConsentStoragePolicy`; a missing value decodes to `nil` (undecided). | extern:UserDefaultsStore keyPrefix + suite, ConsentStoragePolicy encodes accepted/denied — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Storage/UserDefaultsStore.swift#UserDefaultsStore; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/StatefulPolicy.swift#ConsentStoragePolicy | +| `com.contentful.optimization.profile`, `…changes`, `…selectedOptimizations`, `…anonymousId` (`UserDefaults`) | SDK | Profile-continuity cache; written only while `persistenceConsent == true`, cleared when it becomes `false`. `anonymousId` is set to `profile.id`. | extern:UserDefaultsStore profileContinuityKeys written only when persistenceConsent true — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Storage/UserDefaultsStore.swift#UserDefaultsStore | +| anonymous/profile id (in JS core) | SDK | The bridge's `getAnonymousId` returns the stored id only when `signals.persistenceConsent.value === true`, else `undefined`; the id continuity is the same cross-suite `profile` the other SDKs use. | optimization-js-bridge#index.ts#initialize; concept:profile-synchronization-between-client-and-server | +| `nt_experience`, `nt_audience` (content types); `nt_experiences`, `nt_variants`, `nt_audience`, `nt_config`, `nt_experience_id`, `nt_mergetag_id`, `nt_fallback` (fields) | SDK | SDK-fixed Optimization content-model identifiers the resolver keys off — not reader-chosen; shared with the whole suite. | api-schemas#contentful/OptimizedEntry.ts#OptimizedEntrySkeleton; api-schemas#contentful/OptimizationEntry.ts#OptimizationEntrySkeleton; api-schemas#contentful/AudienceEntry.ts#AudienceEntrySkeleton | +| Contentful CDA locale | reader | App chooses the CDA locale and passes it to its own CDA fetch feeding entry resolution; distinct from the SDK Experience/event `locale`. | concept:locale-handling-in-the-optimization-sdk-suite | +| consent record / CMP choice | reader | App records the user's decision and calls `consent(...)`; the SDK reflects only what is passed. | concept:consent-management-in-the-optimization-sdk-suite; core-sdk#CoreStateful.ts#consent | + +- iOS persists to `UserDefaults` keyed under the `com.contentful.optimization.` prefix in a named + suite, not the browser `ctfl-opt-*` cookies the web SDKs use, and shares the AsyncStorage-style key + model with React Native (which stores continuity under separate `__…__` keys). There is no built-in + cross-platform id handoff. source: extern:UserDefaultsStore prefix/suite model — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Storage/UserDefaultsStore.swift#UserDefaultsStore; kb:native/react-native.md + +## Events & tracking + +- Screen events: `.trackScreen(name:)` (SwiftUI) fires on appear, on consent change, and on + screen-name change, calling `trackCurrentScreen(name:)`. `trackCurrentScreen` dedupes in the bridge + by `routeKey` (defaulting to `name`) through an `AcceptedCurrentStateTracker`, so a repeat of the + same current screen is skipped; a blocked attempt is retried once consent allows. Plain + `screen(name:)` calls the core `screen()` with no dedupe. source: extern:.trackScreen fires onAppear/consent/name change → trackCurrentScreen — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift#ScreenTrackingModifier; optimization-js-bridge#index.ts#Bridge; core-sdk#tracking/AcceptedCurrentStateTracker.ts#AcceptedCurrentStateTracker +- Entry view tracking timing (SwiftUI `OptimizedEntry` and UIKit `ViewTrackingController`): defaults + `minVisibleRatio = 0.8`, `dwellTimeMs = 2000`, `viewDurationUpdateIntervalMs = 5000`. Three-phase + cycle: initial `trackView` after accumulated visible time ≥ dwell, periodic duration updates every + interval while visible, and a final duration event when visibility ends (only if ≥1 event already + fired). Gated on `hasConsent("trackView")` (→ core wire type `component`). source: extern:ViewTrackingController three-phase timing, defaults 2000/0.8/5000 — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- Background handling of view cycles: on `UIApplication.didEnterBackgroundNotification` the + controller pauses accumulation, emits a final event if `attempts > 0`, and resets the cycle; on + `didBecomeActive` it re-evaluates visibility from the last known geometry and starts a fresh cycle + when still visible (so nothing needs to scroll to restart). UIKit-only (`#if canImport(UIKit)`). + source: extern:ViewTrackingController pause/resume on background/foreground notifications — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift#ViewTrackingController +- Sticky entry-view dedupe lives in the bridge, not Swift: `trackView` keys sticky views by + `stickyTrackingKey ?? viewId` and only sends a sticky view once accepted, via + `shouldSendStickyEntryView` / `shouldRememberStickyEntryViewResult`. The Swift controller passes a + per-controller `stickyTrackingKey` (a fresh UUID) so a single mounted entry dedupes its own sticky + views. source: optimization-js-bridge#index.ts#Bridge; core-sdk#tracking/EntryViewTracking.ts#shouldSendStickyEntryView; core-sdk#tracking/EntryViewTracking.ts#shouldRememberStickyEntryViewResult +- Entry tap tracking (SwiftUI `TapTrackingModifier`): a `simultaneousGesture(TapGesture())` on the + wrapper emits wire type `component_click` via `trackClick` when `hasConsent("trackClick")`, then + calls `onTap(entry)` if provided. (Unlike React Native, which uses raw touch start/end with a + distance threshold, iOS relies on SwiftUI's `TapGesture`.) source: extern:TapTrackingModifier uses simultaneous TapGesture, gated by hasConsent trackClick — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift#TapTrackingModifier; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- Flags: `getFlag(_:)` is a non-reactive JSON read; `flagPublisher(_:)` registers an `observeFlag` + subscription in the bridge and returns a `CurrentValueSubject`-backed publisher. Subscribing to a + flag observable emits a `component` flag-view event through the core stream when consent and + profile allow, so flag delivery is an analytics exposure. source: extern:getFlag vs flagPublisher/observeFlag — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; optimization-js-bridge#index.ts#Bridge +- `eventStream` is a **passthrough** `PassthroughSubject` (fed by the bridge's `onEventEmitted`) and + does **not** replay prior events to late subscribers; `blockedEventStream` (plus the + `onEventBlocked` config callback) surfaces consent/allow-list-blocked events. Subscribe before init + or accept missing earlier events. source: extern:eventStream/blockedEventStream are passthrough subjects, no replay — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; concept:ios-sdk-runtime-and-interaction-mechanics +- Experience-response payload → published state: an accepted Experience call returns + `{ profile, selectedOptimizations, changes }`; the bridge applies it to core signals and pushes + `readBridgeState()` through `__nativeOnStateChange`, which `OptimizationClient.handleStateUpdate` + decodes into `@Published state`/`selectedOptimizations`/`optimizationPossible`/ + `experienceRequestState` (and writes continuity to `UserDefaults` when permitted). source: optimization-js-bridge#index.ts#initialize; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals; extern:handleStateUpdate decodes pushed bridge state into @Published props — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; kb:shared/concepts.md + +## Consent & persistence + +- Two independent axes — `consent` (may personalize + emit events) and `persistenceConsent` (may + store profile continuity); see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). + Boolean `consent(_ accept:)` sets both; `consent(events:persistence:)` sets them independently + (serialized to the core `ConsentInput` object form). `consent(false)` clears event consent and + persistence consent, purges queues, and clears durable continuity while in-memory state stays + usable until reset/teardown. source: extern:consent(\_:) sets both, consent(events:persistence:) split — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; core-sdk#consent/Consent.ts#ConsentInput; core-sdk#CoreStateful.ts#consent +- Native default pre-consent allow-list = `['identify', 'screen']` + (`DEFAULT_NATIVE_ALLOWED_EVENT_TYPES` in the bridge, used when `allowedEventTypes` is unset), + overriding Core's fail-closed `DEFAULT_ALLOWED_EVENT_TYPES = []`. Before event consent, `identify` + and `screen` emit; entry views (`component`), taps (`component_click`), `page`, and custom `track` + are blocked. Pass `allowedEventTypes: []` for strict opt-in, or a narrow custom list. source: optimization-js-bridge#index.ts#DEFAULT_NATIVE_ALLOWED_EVENT_TYPES; core-sdk#events/EventType.ts#DEFAULT_ALLOWED_EVENT_TYPES; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- `reset()` clears profile continuity (bridge `reset()` — which also clears overrides, the anonymous + id, the current-screen dedupe tracker, and sticky-view keys — plus `store.clearProfileContinuity()`) + but **preserves consent state**. `reset()` no-ops before initialization. source: extern:reset() clears continuity via bridge + store, keeps consent — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; optimization-js-bridge#index.ts#Bridge +- `UserDefaultsStore` is a write-through in-memory cache: getters read only the in-memory `cache` + Map, and setters both update the cache and write JSON/string to `UserDefaults`. Persisted disk + values are read **only at startup** (`loadConsentState`/`loadProfileContinuity`); runtime SDK state + comes from bridge state pushes, never from re-reading disk. Consent stores always; continuity + stores only when `persistenceConsent == true` and is cleared when `false`. Queues are never + persisted (in-memory). source: extern:UserDefaultsStore write-through cache, disk read only at load — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Storage/UserDefaultsStore.swift#UserDefaultsStore +- Persisted persistence-consent falls back to persisted event consent when unset + (`ConsentStoragePolicy.resolvePersistedPersistenceConsent`), mirroring the core storage-decode + behavior. source: extern:resolvePersistedPersistenceConsent falls back to consent — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/StatefulPolicy.swift#ConsentStoragePolicy; core-sdk#consent/ConsentStorage.ts#resolvePersistedPersistenceConsent + +## Version / runtime quirks + +- Beta: the API, setup flow, and bridge contract are subject to change. source: extern:beta status — packages/ios/README.md +- JavaScriptCore bridge: `JSContextManager` creates **one `JSContext` per `OptimizationClient` + lifetime**, registers the native polyfill bindings, evaluates the UMD bundle (polyfills prepended + at build time), verifies `typeof __bridge === "object"`, registers the push-back native globals, + and calls `__bridge.initialize(configJSON)`. The bundle is read from `Bundle.module`; a missing + resource throws `OptimizationError.resourceLoadError`. source: extern:JSContextManager one JSContext per client, loads UMD, validates \_\_bridge — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift#JSContextManager +- `NativePolyfills` registers Swift-backed `__nativeLog`, `__nativeSetTimeout`, + `__nativeClearTimeout`, `__nativeRandomUUID`, and `__nativeFetch` before the bundle evaluates; + `__nativeFetch` uses `URLSession` and brackets each crossing with `os_signpost` performance logs. + The bridge pushes state back via `__nativeOnStateChange`, `__nativeOnEventEmitted`, + `__nativeOnEventBlocked`, `__nativeOnFlagValueChanged`, `__nativeOnOverridesChanged`, + `__nativeOnQueueEvent`. source: extern:NativePolyfills registers native fetch/timers/uuid/log — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift#NativePolyfills; optimization-js-bridge#index.ts#NativeGlobal +- `@MainActor` client: all public bridge calls enter JavaScriptCore from the main actor; async + bridge completions and native fetch/timer completions marshal back to the main queue + (`DispatchQueue.main`) before re-entering JS. source: extern:OptimizationClient is @MainActor and bridge completions dispatch to main — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift#JSContextManager; concept:ios-sdk-runtime-and-interaction-mechanics +- `setLocale(_:)` updates the SDK Experience-API and event locale (and republishes `client.locale`) + but does **not** refetch Contentful entries or refresh profile state; the app must re-fetch CDA and + re-render. It `throws` before init or on an invalid locale. source: extern:setLocale updates SDK locale only, throws pre-init/invalid — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient; concept:locale-handling-in-the-optimization-sdk-suite +- Live-updates / preview precedence: an open preview panel forces live updates in SwiftUI + `OptimizedEntry` (overriding an explicit `liveUpdates: false`); otherwise per-entry `liveUpdates` > + root default > locked. UIKit apps pick their own policy by subscribing to + `$selectedOptimizations`/`$isPreviewPanelOpen`/`$previewState` and redrawing. source: extern:OptimizedEntry preview-open forces live — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift#OptimizedEntry; concept:ios-sdk-runtime-and-interaction-mechanics +- Offline / lifecycle delivery: `NetworkMonitor` (`NWPathMonitor`) calls `setOnline(_:)` on + connectivity change and `flush()` on reconnect; `AppStateHandler` calls `flush()` on + `willResignActive` (UIKit-only) for a best-effort background drain. Queues are in-memory only (no + durable outbox), capped at 100 offline Experience events by default (`OFFLINE_QUEUE_MAX_EVENTS`, + tunable via `QueuePolicy.offlineMaxEvents`); they do not survive process death. source: extern:NetworkMonitor NWPathMonitor + AppStateHandler background flush — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Handlers/NetworkMonitor.swift#NetworkMonitor; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Handlers/AppStateHandler.swift#AppStateHandler; core-sdk#CoreStateful.ts#CoreStatefulConfig +- iOS Simulator reaches host `localhost` directly — there is no localhost rewrite in the SDK, and the + reference app points base URLs straight at `http://localhost:8000` (contrast React Native, which + rewrites `localhost` to `10.0.2.2` on the Android emulator). source: impl:ios-sdk#shared/Config.swift; extern:the iOS Simulator shares the host network so host localhost resolves without a rewrite — packages/ios/README.md +- Remote JS inspection (`JSContext.isInspectable`) is enabled only when `logLevel` is `.debug` or + `.log` (iOS 16.4+/macOS 13.3+), keeping it out of release builds. AppKit is a supported fallback + (macOS 12+), but the `AppStateHandler` background-flush path is UIKit-only. source: extern:isInspectable gated on debug/log logLevel — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift#JSContextManager +- Preview panel: `PreviewPanelOverlay` (SwiftUI FAB + sheet) and `PreviewPanelViewController` + (`.addFloatingButton(to:)` for UIKit) fetch `nt_audience`/`nt_experience` definitions through an + app-supplied `PreviewContentfulClient` (100-entry paginated `ContentfulHTTPPreviewClient`), then + call `loadDefinitions` so the bridge bakes a preview model. Opening/closing the panel toggles + `client.isPreviewPanelOpen` (and the bridge `previewPanelOpen` signal). source: extern:PreviewPanelViewController.addFloatingButton + viewDidAppear toggles preview open — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelViewController.swift#PreviewPanelViewController; optimization-js-bridge#index.ts#Bridge +- Preview panel without a `contentfulClient` (`PreviewPanelConfig(enabled: true)` / omitted client) + still opens, but **degraded, not disabled**: `loadDefinitions()` early-returns on its + `guard let contentfulClient` (nil ⇒ no CDA fetch, `client.loadDefinitions` never called), so the + bridge keeps `audienceDefinitions`/`experienceDefinitions` `null` and `computePreviewModel` returns + `null` with empty `audienceNameMap`/`experienceNameMap`. Confirmed consequences: the "Audiences & + Experiences" section renders "No audience data" (empty `audiencesWithExperiences` ⇒ no `AudienceItem` + rows ⇒ no audience-qualify / variant-select controls for _setting_ overrides); Profile/Debug sections + and the reset-to-actual footer still render from bridge state; any pre-existing overrides (set + imperatively via `overrideAudience`/`overrideVariant`, or restored) still list, but their summaries + show raw audience/experience ids because `activeAudienceOverrides`/`activeVariantOverrides` fall back + `nameMap[id] ?? id`. source: extern:PreviewPanelContent loadDefinitions guards on contentfulClient — no client ⇒ empty audience section, no set-override controls, override summaries fall back to raw ids — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelContent.swift#PreviewPanelContent; optimization-js-bridge#previewStateHelpers.ts#computePreviewModel + +## Failure & fallback behavior + +- Baseline fallback (no matching selection / unresolved links / all-locale payload): resolution + returns the baseline entry and the UI does not break; denied/undecided consent surfaces as "no + selections ⇒ baseline", not a blocked resolve. source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver; concept:entry-personalization-and-variant-resolution; kb:shared/concepts.md +- `OptimizationError` cases: `notInitialized`, `bridgeError(String)`, `resourceLoadError(String)` + (UMD bundle/polyfill resource missing or unreadable), `configError(String)` (locale/serialization + failures). source: extern:OptimizationError cases — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationError.swift#OptimizationError +- Before initialization: async event methods and `setLocale` throw `OptimizationError.notInitialized` + (via `requireInitialized`); synchronous reads/consent/reset/setOnline no-op and + `resolveOptimizedEntry` returns the baseline passthrough, `getFlag`/`getMergeTagValue`/`getProfile` + return `nil`. source: extern:pre-init async methods throw notInitialized while sync APIs no-op or return baseline — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift#OptimizationClient +- JavaScriptCore exceptions are routed, not crashed: `callSync` captures the context exception, + returns `nil`, and logs it; `callAsync` captures the exception and fails the continuation with + `bridgeError`; the context `exceptionHandler` forwards uncaught exceptions to the diagnostic logger. + The bridge itself returns/reports `SDK not initialized. Call initialize() first.` when its + `CoreStateful` instance is absent. source: extern:callSync/callAsync capture JSC exceptions and route to logger/error — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift#JSContextManager; optimization-js-bridge#index.ts#getCurrentInstance +- `UserDefaults` read/write failures are swallowed (`try?` on JSON encode/decode); a schema-invalid + cached value is simply skipped and the SDK continues from in-memory state. source: extern:UserDefaultsStore guards JSON with try? and continues in-memory — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Storage/UserDefaultsStore.swift#UserDefaultsStore +- `PreviewPanelOverlay`/`OptimizationRoot(previewPanel:)` require the client in the environment; the + overlay reads `@EnvironmentObject OptimizationClient`, so it must sit under an `OptimizationRoot`. + The preview `ContentfulHTTPPreviewClient` throws `ContentfulPreviewError` on invalid URL / non-2xx + / unparseable responses. source: extern:PreviewPanelOverlay needs OptimizationClient in environment and ContentfulPreviewError enumerates the fetch failures — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewContentfulClient.swift#ContentfulPreviewError diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 4a1cc3fb..f07e9187 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -24,7 +24,17 @@ add per-archetype checks. nouns to the fields of the SDK's return envelope. It applies wherever the noun first appears in prose, even in a section far from the feature that owns it (e.g. `selected optimizations` and `changes` first surfacing in a Consent or Identity section) — define it there, do not defer to - the owning feature section below. + the owning feature section below. **Result and payload wrapper _types_, not just their fields, + get a first-use gloss and an ownership signal** — a reader must be able to tell an SDK-provided + type (`ResolvedOptimizedEntry`, `TrackingMetadata`, `EventEmissionResult`, `TrackClickPayload`) + from a type they define themselves, and a config type whose name is non-obvious for what it holds + (e.g. consent living inside a `StorageDefaults`) says so at first use. +- [ ] **A published/reactive state field is attributed to the exact object that exposes it.** When + the guide says the SDK "publishes" or "observes" a value, it names whether that value is a + top-level reactive property or a field on a published state snapshot (e.g. on iOS, + `selectedOptimizations`/`locale` are `@Published` on the client, while `profile`/`consent`/ + `changes` are fields on the published `state` snapshot) — it does not list snapshot fields as if + they were top-level published properties. - [ ] **Every SDK-fixed content-model identifier is glossed and its ownership stated at first use.** A fixed Contentful content-type or field name the SDK relies on (`nt_experiences`, `nt_audience`, `nt_experience`) gets a one-line plain-language gloss and a statement that it is SDK-owned, not a @@ -100,6 +110,11 @@ add per-archetype checks. - [ ] Every `###` feature section has a correct `**Integration category:**` line, and its category matches its parent `##` (Required/Common under Core; Optional under Optional; Advanced under Advanced). +- [ ] **No single `###` section bundles multiple independent features under `####` sub-headings.** + Each independent capability the blueprint lists as its own reader goal (e.g. screen tracking vs. + entry-interaction tracking vs. custom events) is its own `###` section with its own category + line and its own TOC entry, not several `####` features sharing one `###` and one category — + such a mega-section is invisible to a TOC reader and blurs distinct categories. - [ ] **A Core section that revisits a quick-start step opens with a bridge, not a verbatim recap.** The first feature section (typically install/initialize) does not repeat the quick start's install/mount steps word-for-word; it opens by naming what is genuinely new below (the full @@ -113,16 +128,34 @@ add per-archetype checks. inspect any accessor, the quick-start snippet the reader just pasted must already expose it — a verify step that depends on an accessor only wired up in a later section is not performable where the reader hits it. +- [ ] **A "read a log line" verify names the recognizable signature to look for.** When the proof is + observed by reading a `logLevel`/console log, presence of the log switch is not enough — a + `.debug`/verbose console is high-noise, so the guide shows the recognizable token, prefix, or + line shape the reader searches for (and the subsystem/filter to narrow to), and that shape + matches what the SDK actually logs. "Watch the console for the event" with no target line is not + performable. +- [ ] **A verify step with an observable failure state wires a diagnostic for the failure path.** If + the proof can render a "blocked"/"failed"/"error" outcome and the shown code `try?`-swallows or + otherwise hides the cause, the quick start gives the reader one way to see _why_ (a `logLevel` + note, an `onEventBlocked`/error callback, or an error stream) — otherwise landing on the failure + branch is a dead end. +- [ ] **The proof's load-bearing state word is defined in the quick start and scoped honestly.** Any + adjective the proof turns on (`accepted`, `resolved`, `ready`) is defined where the quick start + first uses it, not only in a downstream section, and the verify names whose acceptance/readiness + it observes — a client-side signal (a debug log, a returned `accepted` flag) proves the SDK + emitted/allowed the event locally, not that the server received it, so the wording does not imply + server receipt. - [ ] **The quick start is grounded in a real app shape** — no invented fetch shapes (e.g. a hardcoded array of entry IDs). The most common real shape leads; other shapes are pointed to a feature section. - [ ] **Files the reader already owns (layout, providers, renderer, and the runtime root component - such as `App.tsx`) are shown as `+`/`-` diffs that preserve existing content**, not full files - to paste over, and not with the additions blended invisibly into a rewritten file. A reader's - app root is something they always already own, so a full standalone `App` is never a - `**Copy this:**` paste-over. The `+` lines must be unambiguously the additions. A short prose - note states the surrounding code is illustrative context to match against, not a block to paste - verbatim. + such as `App.tsx`, or the native runtime root such as `SceneDelegate`/`AppDelegate` on iOS and + the `Activity`/`Application` on Android) are shown as `+`/`-` diffs that preserve existing + content**, not full files to paste over, and not with the additions blended invisibly into a + rewritten file. A reader's app root is something they always already own, so a full standalone + `App`/`SceneDelegate` is never a `**Copy this:**` paste-over. The `+` lines must be unambiguously + the additions. A short prose note states the surrounding code is illustrative context to match + against, not a block to paste verbatim. - [ ] **Native integration guides put the native build step inline in the quick start.** For iOS / Android / React Native targets, the native install step (`pod install`, `npx expo prebuild`, or equivalent) that must run before the app launches appears in the quick start where the reader