From 19f41d56125b8b12ea8eea30308278a59d459eed Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Fri, 19 Jun 2026 14:45:14 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9E=20fix(consent):=20Fix=20consent-aw?= =?UTF-8?q?are=20event=20gating=20across=20SDK=20runtimes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes consent-aware event gating across the Optimization SDK suite so blocked tracking attempts are no longer treated as successfully emitted events. This updates Core, Web, React Web, React Native, Android, and iOS runtimes to distinguish consent-blocked events from accepted queued events and to emit fresh current-state tracking only when the underlying page, screen, or flag value is still current after consent becomes available. ## What changed - Added emission-result plumbing for page and screen tracking so SDK wrappers only dedupe accepted events. - Introduced SDK support helpers for accepted current-state tracking across shared runtimes. - Added support for allowing `flag` events independently from broader component or entry-view tracking. - Fixed flag-view deduping across consent state, profile availability, profile changes, pending sends, and deep-equal flag values. - Gated automatic page, screen, entry view, hover, click/tap, and viewport tracking on consent across Web, React Web, React Native, Android, and iOS. - Added native bridge support for `hasConsent`, `screenWithEmissionResult`, and configurable `allowedEventTypes`. - Updated documentation for consent behavior and core state management. - Updated bundle-size budgets and package metadata for the new SDK support entrypoints. ## Test coverage Adds or updates coverage for: - Core stateful/stateless consent behavior - Core accepted current-state tracking - Web entry interaction runtime and current page tracking - React Web auto-page emission - React Native navigation, screen, tap, and viewport tracking - Android screen/view tracking and config serialization - iOS config and view tracking behavior - E2E flag and entry-view tracking scenarios [[NT-3519](https://contentful.atlassian.net/browse/NT-3519)] --- .github/workflows/main-pipeline.yaml | 4 +- .github/workflows/publish-android.yaml | 2 + ...anagement-in-the-optimization-sdk-suite.md | 14 +- .../concepts/core-state-management.md | 22 +- .../optimization/app/MainActivity.kt | 2 + .../optimization/app/screens/MainScreen.kt | 8 +- .../optimization/app/views/MainActivity.kt | 7 +- .../ios-sdk/swiftui/Screens/MainScreen.swift | 12 +- .../uikit/Screens/MainViewController.swift | 9 +- .../uitests/Tests/FlagViewTrackingTests.swift | 8 +- .../e2e/entry-view-tracking.spec.ts | 15 + .../e2e/flag-view-tracking.spec.ts | 4 +- implementations/node-sdk+web-sdk/package.json | 2 +- implementations/node-sdk+web-sdk/src/app.ts | 7 +- .../node-sdk+web-sdk/src/index.ejs | 36 +- implementations/node-sdk/package.json | 2 +- implementations/react-native-sdk/App.tsx | 12 +- .../package.json | 2 +- .../package.json | 2 +- .../e2e/flag-view-tracking.spec.ts | 14 +- implementations/react-web-sdk/package.json | 2 +- implementations/react-web-sdk/src/App.tsx | 4 +- .../web-sdk/e2e/flag-view-tracking.spec.ts | 14 +- implementations/web-sdk/package.json | 2 +- implementations/web-sdk/public/index.html | 37 +- .../src/app/components/control-panel/index.ts | 2 + .../e2e/flag-view-tracking.spec.ts | 14 +- implementations/web-sdk_react/package.json | 2 +- implementations/web-sdk_react/src/App.tsx | 4 +- package.json | 2 +- .../ContentfulOptimization/build.gradle.kts | 32 +- .../compose/ClickTrackingModifier.kt | 22 +- .../compose/ScreenTrackingEffect.kt | 19 +- .../compose/ViewTrackingLayout.kt | 14 + .../optimization/core/OptimizationClient.kt | 32 ++ .../optimization/core/OptimizationConfig.kt | 2 + .../tracking/ScreenTrackingState.kt | 31 ++ .../tracking/ViewTrackingController.kt | 15 + .../optimization/views/OptimizationManager.kt | 1 + .../optimization/views/OptimizedEntryView.kt | 51 ++- .../optimization/views/ScreenTracker.kt | 48 ++- .../compose/ScreenTrackingStateTest.kt | 45 +++ .../core/OptimizationConfigTest.kt | 27 ++ .../tracking/ViewTrackingControllerTest.kt | 34 ++ .../Core/OptimizationClient.swift | 32 ++ .../Core/OptimizationConfig.swift | 8 +- .../Tracking/ScreenTrackingState.swift | 29 ++ .../Tracking/TapTrackingModifier.swift | 16 +- .../Tracking/ViewTrackingController.swift | 8 + .../Tracking/ViewTrackingModifier.swift | 23 +- .../Views/ScreenTrackingModifier.swift | 30 +- .../OptimizationClientTests.swift | 92 ++++- packages/react-native-sdk/package.json | 6 +- .../OptimizationNavigationContainer.test.tsx | 330 +++++++++++++++++- .../OptimizationNavigationContainer.tsx | 101 +++++- .../src/hooks/useOptimizationConsentState.ts | 26 ++ .../src/hooks/useScreenTracking.test.ts | 87 ++++- .../src/hooks/useScreenTracking.ts | 46 ++- .../src/hooks/useTapTracking.test.ts | 151 ++++++++ .../src/hooks/useTapTracking.ts | 26 +- .../src/hooks/useViewportTracking.test.ts | 36 ++ .../src/hooks/useViewportTracking.ts | 108 +++--- packages/universal/core-sdk/README.md | 10 + packages/universal/core-sdk/package.json | 18 +- packages/universal/core-sdk/rslib.config.ts | 1 + .../src/CoreStateful.detached-states.test.ts | 12 +- .../core-sdk/src/CoreStateful.test.ts | 327 +++++++++++++++++ .../universal/core-sdk/src/CoreStateful.ts | 15 +- .../core-sdk/src/CoreStatefulEventEmitter.ts | 263 +++++++++++--- .../core-sdk/src/CoreStateless.test.ts | 73 ++++ .../universal/core-sdk/src/CoreStateless.ts | 8 +- .../core-sdk/src/CoreStatelessRequest.ts | 11 +- .../core-sdk/src/EventEmissionResult.ts | 12 + packages/universal/core-sdk/src/EventType.ts | 11 + .../AcceptedCurrentStateTracker.test.ts | 105 ++++++ .../AcceptedCurrentStateTracker.ts | 91 +++++ .../src/sdk-support/CoreStatefulSdkSupport.ts | 47 +++ .../core-sdk/src/sdk-support/README.md | 39 +++ .../core-sdk/src/sdk-support/index.ts | 8 + .../optimization-js-bridge/src/index.ts | 32 +- .../src/auto-page/useAutoPageEmitter.test.tsx | 94 ++++- .../src/auto-page/useAutoPageEmitter.ts | 28 +- .../src/context/OptimizationContext.tsx | 1 + .../src/optimized-entry/OptimizedEntry.tsx | 2 +- .../optimized-entry/optimizedEntryUtils.ts | 2 +- .../src/optimized-entry/useOptimizedEntry.ts | 2 +- .../src/provider/OptimizationProvider.tsx | 2 +- .../react-web-sdk/src/test/sdkTestUtils.tsx | 26 +- packages/web/preview-panel/package.json | 6 +- packages/web/web-sdk/package.json | 18 +- packages/web/web-sdk/rslib.config.ts | 2 + .../web/web-sdk/src/ContentfulOptimization.ts | 2 +- .../EntryInteractionRuntime.test.ts | 50 ++- .../entry-tracking/EntryInteractionRuntime.ts | 34 +- .../sdk-support/currentPageTracker.test.ts | 168 +++++++++ .../src/sdk-support/currentPageTracker.ts | 80 +++++ packages/web/web-sdk/src/sdk-support/index.ts | 33 ++ 97 files changed, 2979 insertions(+), 449 deletions(-) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ScreenTrackingState.kt create mode 100644 packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/compose/ScreenTrackingStateTest.kt create mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ScreenTrackingState.swift create mode 100644 packages/react-native-sdk/src/hooks/useOptimizationConsentState.ts create mode 100644 packages/react-native-sdk/src/hooks/useTapTracking.test.ts create mode 100644 packages/universal/core-sdk/src/EventEmissionResult.ts create mode 100644 packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.test.ts create mode 100644 packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.ts create mode 100644 packages/universal/core-sdk/src/sdk-support/CoreStatefulSdkSupport.ts create mode 100644 packages/universal/core-sdk/src/sdk-support/README.md create mode 100644 packages/universal/core-sdk/src/sdk-support/index.ts create mode 100644 packages/web/web-sdk/src/sdk-support/currentPageTracker.test.ts create mode 100644 packages/web/web-sdk/src/sdk-support/currentPageTracker.ts create mode 100644 packages/web/web-sdk/src/sdk-support/index.ts diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 36acd7584..d22094f61 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -1063,12 +1063,12 @@ jobs: uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 # Smoke-test packaging: assemble the AAR + sources/javadoc/POM the release would publish - # (no Central access, no signing) so packaging breaks are caught on PRs. + # (no Central access, no signing) so packaging breaks are caught on PRs. Release publishing + # separately generates and requires Android third-party notices. - name: Verify Maven publishing assembles working-directory: packages/android/ContentfulOptimization run: | ./gradlew publishToMavenLocal -Pcontentful.optimization.version=0.0.0-ci \ - -Pcontentful.optimization.thirdPartyNoticesFile=THIRD_PARTY_NOTICES.txt \ --no-configuration-cache --no-daemon --console=plain e2e-react-native-android: diff --git a/.github/workflows/publish-android.yaml b/.github/workflows/publish-android.yaml index 1ba9967b2..5d16e23ac 100644 --- a/.github/workflows/publish-android.yaml +++ b/.github/workflows/publish-android.yaml @@ -100,6 +100,7 @@ jobs: run: | ./gradlew publishToMavenLocal \ -Pcontentful.optimization.version="$RELEASE_VERSION" \ + -Pcontentful.optimization.requireThirdPartyNotices=true \ --no-configuration-cache --no-daemon --console=plain # Build the signed AAR (+ sources/javadoc/POM) and publish+release it to Maven Central via the @@ -119,4 +120,5 @@ jobs: run: | ./gradlew publishAndReleaseToMavenCentral \ -Pcontentful.optimization.version="$RELEASE_VERSION" \ + -Pcontentful.optimization.requireThirdPartyNotices=true \ --no-configuration-cache --no-daemon --stacktrace diff --git a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md index 544af8579..c0f825ec9 100644 --- a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md @@ -188,8 +188,14 @@ Allow-listed events can emit before event consent is granted, but their SDK-buil When the consent guard blocks an event, the SDK writes blocked-event metadata and calls the configured `onEventBlocked` callback. You can also subscribe to `states.blockedEventStream` in stateful JavaScript SDKs. Blocked events are dropped at the SDK boundary and are not replayed after -`consent(true)`. If the application needs an event after consent, emit a fresh `page()`, -`identify()`, `screen()`, or interaction event when policy allows. +`consent(true)`. Do not store or resend suppressed interaction events such as clicks, taps, hovers, +or custom interactions. + +SDK-owned current-state surfaces can still emit a fresh event after consent when the underlying +condition is still current and the event has not already been accepted. Automatic page or screen +trackers can emit the active page or screen after tracking becomes allowed, and active flag +subscriptions can emit a flag-view event for the current flag value. Those emissions represent the +current application state after consent, not replay of the previously blocked event. ### Revocation and profile cleanup @@ -366,5 +372,5 @@ Before releasing a consent-aware Optimization SDK integration, verify these impl third-party destinations. - Subscribe to `states.blockedEventStream` or use `onEventBlocked` during validation to confirm denied events are blocked. -- Verify post-consent flows emit fresh events instead of relying on replay of previously blocked - events. +- Verify post-consent flows never replay suppressed interactions, and that page, screen, or flag + current-state emitters send only fresh events for conditions that are still current. diff --git a/documentation/concepts/core-state-management.md b/documentation/concepts/core-state-management.md index d0c9d4617..7b0e5fbb1 100644 --- a/documentation/concepts/core-state-management.md +++ b/documentation/concepts/core-state-management.md @@ -113,10 +113,10 @@ change Core state or event streams are: | `trackClick(payload)` | Sends an Insights event. | | `trackHover(payload)` | Sends an Insights event. | | `trackFlagView(payload)` | Sends an Insights event recording a Custom Flag observation. | -| `getFlag(name)` | Resolves a Custom Flag value from `changes`; emits a flag view event when the resolved value changes. | -| `states.flag(name).current` | Reads the current Custom Flag value and emits a flag view event for that read. | -| `states.flag(name).subscribe()` | Subscribes to distinct Custom Flag values and emits a flag view event for each delivered value. | -| `states.flag(name).subscribeOnce()` | Waits for the first non-nullish Custom Flag value and emits a flag view event for that value. | +| `getFlag(name)` | Resolves a Custom Flag value from `changes`; emits a deduped flag view event for the resolved value. | +| `states.flag(name).current` | Reads the current Custom Flag value and emits a deduped flag view event for that value. | +| `states.flag(name).subscribe()` | Subscribes to distinct Custom Flag values and emits deduped flag view events for delivered values. | +| `states.flag(name).subscribeOnce()` | Waits for the first non-nullish Custom Flag value and emits a deduped flag view event for that value. | | `reset()` | Clears `blockedEvent`, `event`, `changes`, `profile`, and `selectedOptimizations` in a single batch. | | `flush()` | Triggers immediate queue flushes without writing to any signal directly. | | `destroy()` | Flushes both queues and releases the singleton lock. | @@ -181,7 +181,9 @@ via the `allowedEventTypes` configuration option. Calling `consent(true)` unblocks all gated events going forward and grants durable profile-continuity persistence consent. Calling `consent({ events: true, persistence: false })` allows event emission while keeping profile continuity session-only. Blocked events are not replayed -after consent is granted. +after consent is granted. Current-state SDK surfaces, such as automatic page or screen trackers and +active flag subscriptions, may emit a fresh event after consent when the same page, screen, or flag +value is still current and has not already produced an accepted event. ## What consumers receive: the `states` surface @@ -300,10 +302,12 @@ const darkModeSubscription = sdk.states.flag('dark-mode').subscribe((value) => { }) ``` -`states.flag(name).subscribe()` suppresses duplicate emitted values using deep equality and emits a -flag view event for each delivered value. `states.flag(name).current` represents a direct read, so -each `current` read emits a flag view event. `getFlag(name)` is nonreactive and deduplicates flag -view events when repeated calls resolve the same value. +`states.flag(name).subscribe()` suppresses duplicate emitted values using deep equality and attempts +to emit a flag view event for each delivered value. `states.flag(name).current` represents a direct +read, and `getFlag(name)` is nonreactive. All flag-view paths deduplicate accepted flag-view +signatures, so repeated reads of the same value for the same active profile do not emit duplicate +flag-view events. A read blocked before consent does not count as accepted, so the current flag +value can still produce a fresh flag-view event after consent is granted. If you forward Custom Flag values to a third-party analytics destination, use the same flag read or render path that your application already owns. Adding a `states.flag(name)` subscription only for diff --git a/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt index 631b6080d..8ce58bd91 100644 --- a/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import com.contentful.optimization.app.screens.MainScreen import com.contentful.optimization.compose.OptimizationRoot import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.core.StorageDefaults import com.contentful.optimization.preview.PreviewPanelConfig import com.contentful.optimization.shared.AppConfig import com.contentful.optimization.shared.MockPreviewContentfulClient @@ -47,6 +48,7 @@ class MainActivity : ComponentActivity() { experienceBaseUrl = AppConfig.experienceBaseUrl, insightsBaseUrl = AppConfig.insightsBaseUrl, locale = AppConfig.defaultContentfulLocale, + defaults = StorageDefaults(consent = true), debug = true, ), trackViews = true, diff --git a/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt index 24538b911..cbb997d4b 100644 --- a/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt @@ -44,12 +44,11 @@ fun MainScreen() { var entries by remember { mutableStateOf>>(emptyList()) } var showNavigationTest by remember { mutableStateOf(false) } var showLiveUpdatesTest by remember { mutableStateOf(false) } - var flagSubscribed by remember { mutableStateOf(false) } var viewportHeight by remember { mutableStateOf(0f) } LaunchedEffect(Unit) { EventStore.subscribe(client.events, scope) - client.consent(true) + client.subscribeToFlag("boolean") try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} } @@ -71,10 +70,6 @@ fun MainScreen() { AppConfig.entryIds, AppConfig.defaultContentfulLocale, ) - if (!flagSubscribed) { - flagSubscribed = true - client.subscribeToFlag("boolean") - } } } @@ -123,7 +118,6 @@ fun MainScreen() { if (entries.isEmpty()) { Text("Loading...") } else { - val scrollContext = remember(viewportHeight) { ScrollContext(scrollY = 0f, viewportHeight = viewportHeight) } diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt index 78f80fb27..bb6c30e97 100644 --- a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt @@ -15,6 +15,7 @@ import com.contentful.optimization.app.views.components.NestedContentEntryViewBi import com.contentful.optimization.app.views.components.isNestedContent import com.contentful.optimization.app.views.support.setTestTag import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.core.StorageDefaults import com.contentful.optimization.preview.PreviewPanelConfig import com.contentful.optimization.shared.AppConfig import com.contentful.optimization.shared.ContentfulFetcher @@ -70,6 +71,7 @@ class MainActivity : AppCompatActivity() { experienceBaseUrl = AppConfig.experienceBaseUrl, insightsBaseUrl = AppConfig.insightsBaseUrl, locale = AppConfig.defaultContentfulLocale, + defaults = StorageDefaults(consent = true), debug = true, ), trackViews = true, @@ -117,7 +119,7 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(this, LiveUpdatesTestActivity::class.java)) } - // Mirrors MainScreen.LaunchedEffect(Unit): subscribe events, consent, page, optional + // Mirrors MainScreen.LaunchedEffect(Unit): subscribe events, flag, page, optional // offline. The Compose impl gates rendering on `client.isInitialized` via // OptimizationRoot, so its content's LaunchedEffects always see an initialized client. // The Views impl renders immediately, so we wait for init here before driving the SDK — @@ -125,7 +127,7 @@ class MainActivity : AppCompatActivity() { EventStore.subscribe(client.events, lifecycleScope) lifecycleScope.launch { client.isInitialized.first { it } - client.consent(true) + client.subscribeToFlag("boolean") try { client.page(mapOf("url" to "app")) } catch (_: Exception) { @@ -154,7 +156,6 @@ class MainActivity : AppCompatActivity() { // diffing keeps existing nodes when the data is identical. if (entriesLoaded) return@collect entriesLoaded = true - client.subscribeToFlag("boolean") val entries = ContentfulFetcher.fetchEntries( AppConfig.entryIds, AppConfig.defaultContentfulLocale, diff --git a/implementations/ios-sdk/swiftui/Screens/MainScreen.swift b/implementations/ios-sdk/swiftui/Screens/MainScreen.swift index 635cdb889..d49ed0819 100644 --- a/implementations/ios-sdk/swiftui/Screens/MainScreen.swift +++ b/implementations/ios-sdk/swiftui/Screens/MainScreen.swift @@ -84,7 +84,10 @@ struct MainScreen: View { // subscription (e.g. in a child view's onAppear) would miss it — // `eventPublisher` is a PassthroughSubject and does not buffer. EventStore.shared.subscribe(to: client.eventPublisher) - client.consent(true) + if !flagSubscribed { + flagSubscribed = true + client.subscribeToFlag("boolean") + } _ = try? await client.page(properties: ["url": "app"]) } .onReceive( @@ -98,13 +101,6 @@ struct MainScreen: View { } ) { profile in guard profile != nil else { return } - // Subscribe to the `boolean` flag once a profile (and consent) is - // available so a flag-view `component` event is emitted — mirrors - // the React Native app's gated `sdk.states.flag(...).subscribe(...)`. - if !flagSubscribed { - flagSubscribed = true - client.subscribeToFlag("boolean") - } Task { entries = await ContentfulFetcher.fetchEntries( ids: AppConfig.entryIds, diff --git a/implementations/ios-sdk/uikit/Screens/MainViewController.swift b/implementations/ios-sdk/uikit/Screens/MainViewController.swift index b9f68218f..10613988c 100644 --- a/implementations/ios-sdk/uikit/Screens/MainViewController.swift +++ b/implementations/ios-sdk/uikit/Screens/MainViewController.swift @@ -7,7 +7,6 @@ final class MainViewController: UIViewController { private let client: OptimizationClient private var entries: [[String: Any]] = [] private var firstAppearHandled = false - private var flagSubscribed = false private var cancellables = Set() private let identifyButton = UIButton(type: .system) @@ -39,6 +38,7 @@ final class MainViewController: UIViewController { EventStore.shared.subscribe(to: client.eventPublisher) analyticsView.bind(to: EventStore.shared) + client.subscribeToFlag("boolean") client.$state .map { $0.profile } @@ -52,12 +52,6 @@ final class MainViewController: UIViewController { guard let self else { return } self.updateIdentifyControls(profile: profile) guard profile != nil else { return } - // Subscribe to the `boolean` flag once a profile (and consent) - // is available so a flag-view `component` event is emitted. - if !self.flagSubscribed { - self.flagSubscribed = true - self.client.subscribeToFlag("boolean") - } Task { @MainActor in let fetched = await ContentfulFetcher.fetchEntries( ids: AppConfig.entryIds, @@ -75,7 +69,6 @@ final class MainViewController: UIViewController { guard !firstAppearHandled else { return } firstAppearHandled = true - client.consent(true) Task { @MainActor in _ = try? await client.page(properties: ["url": "app"]) } diff --git a/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift b/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift index 099c89b9d..3a867310b 100644 --- a/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift +++ b/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift @@ -5,11 +5,9 @@ import XCTest /// Verifies that when an app subscribes to a flag through the SDK, the SDK emits a /// view event for that flag so the value's exposure can be measured downstream. /// -/// Platform note: the pseudocode contract subscribes to the `boolean` flag on app -/// launch and asserts a view event for it. The iOS implementation app shells -/// (`swiftui`/`uikit`) do not currently call `sdk.states.flag("boolean").subscribe()`, -/// so the `event-count-boolean` stats label is not produced. See the report for this -/// app-side gap. +/// Platform note: both iOS implementation app shells subscribe to the `boolean` +/// flag on launch through the native SDK wrapper so this E2E test can assert +/// the SDK-emitted flag view event without app-level consent workarounds. final class FlagViewTrackingTests: XCTestCase { let app = XCUIApplication() diff --git a/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts b/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts index 20e91c8df..e046fa657 100644 --- a/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts @@ -76,6 +76,21 @@ test.describe('entry view tracking', () => { ).toBeVisible() }) + test('same-route page event is not duplicated by repeated consent acceptance', async ({ + page, + }) => { + const pageEvents = page + .getByRole('listitem') + .filter({ has: page.getByRole('button', { name: 'page' }) }) + + await expect(pageEvents).toHaveCount(1) + + await page.getByRole('button', { name: 'Reject Consent' }).click() + await page.getByRole('button', { name: 'Accept Consent' }).click() + + await expect(pageEvents).toHaveCount(1) + }) + test('entry view events have been emitted', async ({ page }) => { for (const entryId of Object.keys(variantEntryTexts)) { const entryText = variantEntryTexts[entryId] diff --git a/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts b/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts index 9ca6e732d..71870871c 100644 --- a/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts @@ -14,14 +14,14 @@ test.describe('flag view tracking', () => { await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible() }) - test('flag access emits a flag view event', async ({ page }) => { + test('flag subscription emits a flag view event', async ({ page }) => { await page.goto('/user/flag-access-e2e') await page.waitForLoadState('domcontentloaded') const flagAccessEvents = getFlagAccessEvents(page) await expect .poll(async () => await flagAccessEvents.count(), { - message: 'flag access should append a flag view event in the event stream', + message: 'active flag subscription should append a flag view event in the event stream', }) .toBeGreaterThan(0) diff --git a/implementations/node-sdk+web-sdk/package.json b/implementations/node-sdk+web-sdk/package.json index a86c375fd..3165b583f 100644 --- a/implementations/node-sdk+web-sdk/package.json +++ b/implementations/node-sdk+web-sdk/package.json @@ -15,7 +15,7 @@ "serve:mocks": "pm2 start --name esr-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop esr-mocks && pm2 delete esr-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", - "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e": "sh -c 'pnpm serve && playwright test \"$@\"; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT' --", "test:e2e:codegen": "playwright codegen", "test:e2e:report": "playwright show-report", "test:e2e:ui": "playwright test --ui", diff --git a/implementations/node-sdk+web-sdk/src/app.ts b/implementations/node-sdk+web-sdk/src/app.ts index dd65e61b7..a92f83305 100644 --- a/implementations/node-sdk+web-sdk/src/app.ts +++ b/implementations/node-sdk+web-sdk/src/app.ts @@ -62,6 +62,7 @@ interface RenderResponseOptions { readonly appConsent: boolean | undefined readonly appLocale: string readonly id?: string + readonly optimizationData?: OptimizationData readonly userId?: string } @@ -127,7 +128,7 @@ function getAppConsentFromCookies(cookies: unknown): boolean | undefined { function respond( res: Response, - { appConsent, appLocale, id, userId }: RenderResponseOptions, + { appConsent, appLocale, id, optimizationData, userId }: RenderResponseOptions, ): void { if (appConsent === true && id) { res.cookie(ANONYMOUS_ID_COOKIE, id, { @@ -143,6 +144,7 @@ function respond( appConsent: appConsent ?? null, appLocale, identified: userId, + optimizationData: optimizationData ?? null, }) } @@ -194,6 +196,7 @@ app.get('/', limiter, async (req, res) => { appConsent, appLocale, id: optimizationData?.profile.id, + optimizationData, }) }) app.get('/smoke-test', limiter, (_, res) => { @@ -201,6 +204,7 @@ app.get('/smoke-test', limiter, (_, res) => { appConsent: null, config, appLocale: APP_LOCALE, + optimizationData: null, }) }) app.get('/user/:id', limiter, async (req, res) => { @@ -213,6 +217,7 @@ app.get('/user/:id', limiter, async (req, res) => { appConsent, appLocale, id: optimizationData?.profile.id, + optimizationData, userId, }) }) diff --git a/implementations/node-sdk+web-sdk/src/index.ejs b/implementations/node-sdk+web-sdk/src/index.ejs index 66020d4a8..e35ecabb0 100644 --- a/implementations/node-sdk+web-sdk/src/index.ejs +++ b/implementations/node-sdk+web-sdk/src/index.ejs @@ -203,15 +203,28 @@ const CONFIG = <%- JSON.stringify(config) %> const APP_LOCALE = <%- JSON.stringify(appLocale) %> const SERVER_APP_CONSENT = <%- JSON.stringify(appConsent) %> + const SERVER_OPTIMIZATION_DATA = <%- JSON.stringify(optimizationData) %> diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index e10cb8311..75205efe9 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -21,6 +21,8 @@ export class ControlPanel { protected readonly optimizationCount = computed( () => this.optimization.selectedOptimizations()?.length ?? 0, ) + // This is an active exposure stream. Core does not mark one-off flag reads as + // tracked until a flag-view event is actually accepted. protected readonly booleanFlag = fromSdkState( this.optimization.sdk.states.flag('boolean'), ) diff --git a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts index f5702b24d..8da74f3f8 100644 --- a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts +++ b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts @@ -18,27 +18,19 @@ test.describe('flag view tracking', () => { await expect(flagEvents).toHaveCount(0) }) - test('emits flag view events after consent and profile updates', async ({ page }) => { + test('emits flag view events after consented profile updates', async ({ page }) => { const flagEvents = page.locator('[data-testid="event-component-boolean"]') const baselineFlagEventCount = await flagEvents.count() await page.getByTestId('consent-button').click() - await expect - .poll(async () => await flagEvents.count(), { - message: 'consented flag subscription should emit a flag view event', - }) - .toBeGreaterThan(baselineFlagEventCount) - - const afterConsentFlagEventCount = await flagEvents.count() - await page.getByTestId('live-updates-identify-button').click() await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() await expect .poll(async () => await flagEvents.count(), { - message: 'profile updates should emit additional flag view events', + message: 'consented profile updates should emit flag view events', }) - .toBeGreaterThan(afterConsentFlagEventCount) + .toBeGreaterThan(baselineFlagEventCount) }) }) diff --git a/implementations/web-sdk_react/package.json b/implementations/web-sdk_react/package.json index 0c57dc0e2..07b80680c 100644 --- a/implementations/web-sdk_react/package.json +++ b/implementations/web-sdk_react/package.json @@ -16,7 +16,7 @@ "serve:mocks": "pm2 start --name web-sdk_react-mocks \"pnpm --dir ../../lib/mocks serve\"", "serve:mocks:stop": "pm2 stop web-sdk_react-mocks && pm2 delete web-sdk_react-mocks", "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", - "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e": "sh -c 'pnpm serve && playwright test \"$@\"; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT' --", "test:e2e:codegen": "playwright codegen", "test:e2e:report": "playwright show-report", "test:e2e:ui": "playwright test --ui", diff --git a/implementations/web-sdk_react/src/App.tsx b/implementations/web-sdk_react/src/App.tsx index 009e16632..38235e489 100644 --- a/implementations/web-sdk_react/src/App.tsx +++ b/implementations/web-sdk_react/src/App.tsx @@ -58,7 +58,7 @@ export default function App({ }, [location.pathname, sdk]) useEffect(() => { - if (sdk === undefined || consent !== true || profile === undefined) { + if (sdk === undefined) { return } @@ -67,7 +67,7 @@ export default function App({ return () => { subscription.unsubscribe() } - }, [consent, profile?.id, sdk]) + }, [sdk]) useEffect(() => { if (sdk === undefined) { diff --git a/package.json b/package.json index f5b075a25..3e0c13b42 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "engines": { "node": ">=20.19.0" }, - "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620", + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", "devDependencies": { "@rstest/core": "catalog:", "@eslint/js": "^10.0.1", diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index aae219174..9551ae9ed 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -94,6 +94,10 @@ val thirdPartyNoticesFile = "build/reports/third-party-notices/android-published-third-party-notices.txt", ), ) +val requireThirdPartyNotices = + providers.gradleProperty("contentful.optimization.requireThirdPartyNotices") + .map { it.toBoolean() } + .orElse(false) val licenseFile = repoRoot.resolve("LICENSE") val generatedThirdPartyNoticesAssetsDir = layout.buildDirectory.dir("generated/third-party-notices/assets") @@ -102,9 +106,19 @@ val copyThirdPartyNotices = tasks.register("copyThirdPartyNotices") { it.file("THIRD_PARTY_NOTICES.txt") } val licenseOutputFile = generatedThirdPartyNoticesAssetsDir.map { it.file("LICENSE") } + val existingThirdPartyNoticesFile = providers.provider { + thirdPartyNoticesFile.get().takeIf { it.isFile } + } - inputs.file(thirdPartyNoticesFile) + inputs.property("thirdPartyNoticesFilePath", thirdPartyNoticesFile.map { it.absolutePath }) + inputs.property("requireThirdPartyNotices", requireThirdPartyNotices) + inputs.file(existingThirdPartyNoticesFile) + .withPropertyName("thirdPartyNoticesFile") + .withPathSensitivity(PathSensitivity.RELATIVE) + .optional(true) inputs.file(licenseFile) + .withPropertyName("licenseFile") + .withPathSensitivity(PathSensitivity.RELATIVE) outputs.files(noticesOutputFile, licenseOutputFile) doLast { @@ -113,14 +127,20 @@ val copyThirdPartyNotices = tasks.register("copyThirdPartyNotices") { val noticesTargetFile = noticesOutputFile.get().asFile val licenseTargetFile = licenseOutputFile.get().asFile - check(noticesFile.isFile) { - "Missing $noticesFile. Run pnpm notices:generate:android before publishing." + outputDir.deleteRecursively() + + if (!noticesFile.isFile) { + check(!requireThirdPartyNotices.get()) { + "Missing $noticesFile. Run pnpm notices:generate:android before publishing." + } + logger.lifecycle("Skipping Android third-party notices asset because $noticesFile does not exist.") + return@doLast } + check(licenseFile.isFile) { "Missing $licenseFile." } - outputDir.deleteRecursively() outputDir.mkdirs() noticesFile.copyTo(noticesTargetFile, overwrite = true) licenseFile.copyTo(licenseTargetFile, overwrite = true) @@ -158,7 +178,9 @@ tasks.matching { it.name == "publishAndReleaseToMavenCentral" || it.name.startsWith("publishMavenPublicationTo") }.configureEach { - dependsOn(verifyThirdPartyNoticesInReleaseAar) + if (requireThirdPartyNotices.get()) { + dependsOn(verifyThirdPartyNoticesInReleaseAar) + } } // Maven Central publishing via the Sonatype Central Portal. The vanniktech plugin configures the diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt index acc549716..61bfc94af 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt @@ -21,16 +21,18 @@ fun Modifier.trackClicks( val scope = rememberCoroutineScope() return this.clickable { - val metadata = TrackingMetadata(entry, personalization) - val payload = TrackClickPayload( - componentId = metadata.componentId, - experienceId = metadata.experienceId, - variantIndex = metadata.variantIndex, - ) - scope.launch { - try { - client.trackClick(payload) - } catch (_: Exception) { + if (client.hasConsent("trackClick")) { + val metadata = TrackingMetadata(entry, personalization) + val payload = TrackClickPayload( + componentId = metadata.componentId, + experienceId = metadata.experienceId, + variantIndex = metadata.variantIndex, + ) + scope.launch { + try { + client.trackClick(payload) + } catch (_: Exception) { + } } } onTap?.invoke(entry) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt index 77b6cba1c..951e48589 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt @@ -2,14 +2,29 @@ package com.contentful.optimization.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.contentful.optimization.tracking.ScreenTrackingState @Composable fun ScreenTrackingEffect(screenName: String) { val client = LocalOptimizationClient.current - LaunchedEffect(screenName) { + val state by client.state.collectAsState() + val trackingAllowed = state.consent == true || client.hasConsent("screen") + val trackingState = remember(client) { ScreenTrackingState() } + + LaunchedEffect(screenName, trackingAllowed) { + if (!trackingState.shouldTrack(screenName, trackingAllowed)) return@LaunchedEffect + + trackingState.markInFlight(screenName) try { - client.screen(name = screenName) + if (client.screenWithEmissionResult(name = screenName).accepted) { + trackingState.markAccepted(screenName) + } } catch (_: Exception) { + } finally { + trackingState.clearInFlight(screenName) } } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt index d4b510f82..b05987e15 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt @@ -3,6 +3,9 @@ package com.contentful.optimization.compose import android.content.res.Resources import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LayoutCoordinates @@ -21,6 +24,9 @@ fun Modifier.trackViews( enabled: Boolean, client: OptimizationClient, ): Modifier { + val state by client.state.collectAsState() + val trackingAllowed = state.consent == true || client.hasConsent("trackView") + if (!enabled) return this val scrollContext = LocalScrollContext.current @@ -42,6 +48,14 @@ fun Modifier.trackViews( } } + LaunchedEffect(controller, trackingAllowed) { + if (trackingAllowed) { + controller.reevaluateVisibility() + } else { + controller.onDisappear() + } + } + return this.onGloballyPositioned { coordinates -> updateControllerVisibility(controller, coordinates, scrollContext) } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt index 3dc0ed331..3ac2c5336 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt @@ -24,6 +24,11 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +internal data class EventEmissionResult( + val accepted: Boolean, + val data: Map?, +) + class OptimizationClient(private val applicationContext: Context) { private val _state = MutableStateFlow(OptimizationState.EMPTY) @@ -143,6 +148,24 @@ class OptimizationClient(private val applicationContext: Context) { } } + internal suspend fun screenWithEmissionResult( + name: String, + properties: Map? = null, + ): EventEmissionResult { + val result = bridgeCallAsyncJSON("screenWithEmissionResult") { + val obj = JSONObject() + obj.put("name", name) + properties?.let { obj.put("properties", JSONObject(it)) } + obj.toString() + } ?: return EventEmissionResult(accepted = false, data = null) + + @Suppress("UNCHECKED_CAST") + return EventEmissionResult( + accepted = result["accepted"] as? Boolean ?: false, + data = result["data"] as? Map, + ) + } + suspend fun flush() { bridgeCallAsyncVoid("flush", "") } @@ -248,6 +271,15 @@ class OptimizationClient(private val applicationContext: Context) { fun getState(): OptimizationState = _state.value + fun hasConsent(method: String): Boolean { + if (!_isInitialized.value) return false + val escapedMethod = escapeForJS(method) + val result = runBlocking(bridge.quickJsDispatcher) { + bridge.callSync("hasConsent", "'$escapedMethod'") + } + return result == "true" + } + // MARK: - Preview Panel fun setPreviewPanelOpen(open: Boolean) { diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt index 3368b876d..f18addd21 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt @@ -31,6 +31,7 @@ data class OptimizationConfig( val locale: String? = null, var defaults: StorageDefaults? = null, val debug: Boolean = false, + val allowedEventTypes: List? = null, ) { fun normalizedLocale(): String? = locale?.let { normalizeExplicitLocale(it, "locale") } @@ -42,6 +43,7 @@ data class OptimizationConfig( experienceBaseUrl?.let { obj.put("experienceBaseUrl", it) } insightsBaseUrl?.let { obj.put("insightsBaseUrl", it) } normalizedLocale()?.let { obj.put("locale", it) } + allowedEventTypes?.let { obj.put("allowedEventTypes", org.json.JSONArray(it)) } if (defaults != null || anonymousId != null) { val defaultsObj = JSONObject() diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ScreenTrackingState.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ScreenTrackingState.kt new file mode 100644 index 000000000..8c217d2ce --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ScreenTrackingState.kt @@ -0,0 +1,31 @@ +package com.contentful.optimization.tracking + +internal class ScreenTrackingState { + private var lastAcceptedScreenName: String? = null + private var inFlightScreenName: String? = null + + fun shouldTrack(screenName: String, trackingAllowed: Boolean): Boolean { + return trackingAllowed && + lastAcceptedScreenName != screenName && + inFlightScreenName != screenName + } + + fun markInFlight(screenName: String) { + inFlightScreenName = screenName + } + + fun markAccepted(screenName: String) { + lastAcceptedScreenName = screenName + } + + fun clearInFlight(screenName: String) { + if (inFlightScreenName == screenName) { + inFlightScreenName = null + } + } + + fun reset() { + lastAcceptedScreenName = null + inFlightScreenName = null + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt index 2570048e8..e6c960a9a 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt @@ -31,6 +31,7 @@ class ViewTrackingController internal constructor( entry: Map, personalization: Map?, private val onTrackView: suspend (TrackViewPayload) -> Unit, + private val isTrackingAllowed: () -> Boolean = { true }, private val threshold: Double = 0.8, private val viewTimeMs: Int = 2000, private val viewDurationUpdateIntervalMs: Int = 5000, @@ -55,6 +56,7 @@ class ViewTrackingController internal constructor( entry = entry, personalization = personalization, onTrackView = { payload -> client.trackView(payload) }, + isTrackingAllowed = { client.hasConsent("trackView") }, threshold = threshold, viewTimeMs = viewTimeMs, viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, @@ -94,6 +96,13 @@ class ViewTrackingController internal constructor( lastScrollY = scrollY lastViewportHeight = viewportHeight + if (!isTrackingAllowed()) { + if (isVisible) { + onBecameInvisible() + } + return + } + val visibleTop = maxOf(elementY, scrollY) val visibleBottom = minOf(elementY + elementHeight, scrollY + viewportHeight) val visibleHeight = maxOf(0f, visibleBottom - visibleTop) @@ -116,6 +125,10 @@ class ViewTrackingController internal constructor( } } + fun reevaluateVisibility() { + updateVisibility(lastElementY, lastElementHeight, lastScrollY, lastViewportHeight) + } + fun onDisappear() { if (isVisible) { onBecameInvisible() @@ -207,6 +220,8 @@ class ViewTrackingController internal constructor( } private fun emitEvent() { + if (!isTrackingAllowed()) return + val currentViewId = viewId ?: return val payload = TrackViewPayload( componentId = metadata.componentId, diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt index 0aaa51128..05bf0b9d3 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt @@ -113,6 +113,7 @@ object OptimizationManager { * Tear down for testing or hot-reloads. Production apps don't normally call this. */ fun resetForTesting() { + ScreenTracker.resetForTesting() clientRef.getAndSet(null)?.let { c -> runBlocking(Dispatchers.Default) { try { diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt index a456e0bb2..1f635d640 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt @@ -72,6 +72,7 @@ class OptimizedEntryView @JvmOverloads constructor( private var trackingScope: CoroutineScope? = null private var personalizationJob: Job? = null private var previewJob: Job? = null + private var consentJob: Job? = null private var controller: ViewTrackingController? = null private var lockedPersonalizations: List>? = null @@ -139,6 +140,7 @@ class OptimizedEntryView @JvmOverloads constructor( trackingScope = null personalizationJob = null previewJob = null + consentJob = null } private fun restartObservation() { @@ -155,6 +157,7 @@ class OptimizedEntryView @JvmOverloads constructor( controllerPersonalization = null personalizationJob?.cancel() previewJob?.cancel() + consentJob?.cancel() @Suppress("UNCHECKED_CAST") val fields = entry["fields"] as? Map @@ -200,6 +203,13 @@ class OptimizedEntryView @JvmOverloads constructor( } } } + + consentJob = scope.launch { + client.state.collect { + lastResult?.let { result -> attachController(result) } + updateVisibility() + } + } } private fun publishResult(result: PersonalizedResult) { @@ -216,6 +226,17 @@ class OptimizedEntryView @JvmOverloads constructor( private fun attachController(result: PersonalizedResult) { if (!resolveTrackViews()) return + val client = OptimizationManager.client + if (!client.hasConsent("trackView")) { + controller?.let { + it.onDisappear() + it.destroy() + } + controller = null + controllerEntry = null + controllerPersonalization = null + return + } val entry = entry ?: return val newPersonalization = result.personalization @Suppress("UNCHECKED_CAST") @@ -243,7 +264,7 @@ class OptimizedEntryView @JvmOverloads constructor( it.destroy() } controller = ViewTrackingController( - client = OptimizationManager.client, + client = client, entry = entry, personalization = newPersonalization, threshold = threshold, @@ -280,18 +301,22 @@ class OptimizedEntryView @JvmOverloads constructor( private fun fireTrackClick() { if (!resolveTrackTaps()) return val entry = entry ?: return - val scope = trackingScope ?: return - val metadata = TrackingMetadata(entry, lastResult?.personalization) - scope.launch { - try { - OptimizationManager.client.trackClick( - TrackClickPayload( - componentId = metadata.componentId, - experienceId = metadata.experienceId, - variantIndex = metadata.variantIndex, - ), - ) - } catch (_: Exception) { + if (OptimizationManager.client.hasConsent("trackClick")) { + val scope = trackingScope + if (scope != null) { + val metadata = TrackingMetadata(entry, lastResult?.personalization) + scope.launch { + try { + OptimizationManager.client.trackClick( + TrackClickPayload( + componentId = metadata.componentId, + experienceId = metadata.experienceId, + variantIndex = metadata.variantIndex, + ), + ) + } catch (_: Exception) { + } + } } } onTap?.invoke(entry) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt index 2b8a5713c..c5077540c 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt @@ -1,7 +1,10 @@ package com.contentful.optimization.views +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.tracking.ScreenTrackingState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch @@ -24,13 +27,56 @@ import kotlinx.coroutines.launch object ScreenTracker { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val trackingState = ScreenTrackingState() + private var currentScreenName: String? = null + private var observedClient: OptimizationClient? = null + private var stateJob: Job? = null fun trackScreen(name: String) { scope.launch { try { - OptimizationManager.client.screen(name = name) + currentScreenName = name + val client = OptimizationManager.client + observeConsent(client) + trackCurrentScreenIfAllowed(client) } catch (_: Exception) { } } } + + internal fun resetForTesting() { + stateJob?.cancel() + stateJob = null + observedClient = null + currentScreenName = null + trackingState.reset() + } + + private fun observeConsent(client: OptimizationClient) { + if (observedClient === client && stateJob?.isActive == true) return + + stateJob?.cancel() + observedClient = client + stateJob = scope.launch { + client.state.collect { + trackCurrentScreenIfAllowed(client) + } + } + } + + private suspend fun trackCurrentScreenIfAllowed(client: OptimizationClient) { + val screenName = currentScreenName ?: return + val trackingAllowed = client.getState().consent == true || client.hasConsent("screen") + + if (!trackingState.shouldTrack(screenName, trackingAllowed)) return + + trackingState.markInFlight(screenName) + try { + if (client.screenWithEmissionResult(name = screenName).accepted) { + trackingState.markAccepted(screenName) + } + } finally { + trackingState.clearInFlight(screenName) + } + } } diff --git a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/compose/ScreenTrackingStateTest.kt b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/compose/ScreenTrackingStateTest.kt new file mode 100644 index 000000000..c8e05dfd9 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/compose/ScreenTrackingStateTest.kt @@ -0,0 +1,45 @@ +package com.contentful.optimization.compose + +import com.contentful.optimization.tracking.ScreenTrackingState +import org.junit.Assert.assertEquals +import org.junit.Test + +class ScreenTrackingStateTest { + + @Test + fun `tracks current screen once when tracking becomes allowed`() { + val state = ScreenTrackingState() + + assertEquals(false, state.shouldTrack("Home", trackingAllowed = false)) + assertEquals(true, state.shouldTrack("Home", trackingAllowed = true)) + + state.markAccepted("Home") + + assertEquals(false, state.shouldTrack("Home", trackingAllowed = false)) + assertEquals(false, state.shouldTrack("Home", trackingAllowed = true)) + } + + @Test + fun `does not duplicate an in-flight screen`() { + val state = ScreenTrackingState() + + assertEquals(true, state.shouldTrack("Home", trackingAllowed = true)) + + state.markInFlight("Home") + + assertEquals(false, state.shouldTrack("Home", trackingAllowed = true)) + + state.clearInFlight("Home") + + assertEquals(true, state.shouldTrack("Home", trackingAllowed = true)) + } + + @Test + fun `tracks changed screens after an accepted screen`() { + val state = ScreenTrackingState() + + state.markAccepted("Home") + + assertEquals(true, state.shouldTrack("Details", trackingAllowed = true)) + } +} diff --git a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt index d0b99d8df..0bb91fb2b 100644 --- a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt +++ b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt @@ -53,4 +53,31 @@ class OptimizationConfigTest { assertEquals("anonymous-id", defaults.getString("anonymousId")) } + @Test + fun `serializes allowed event types`() { + val config = OptimizationConfig( + clientId = "test-client", + allowedEventTypes = listOf("identify", "screen", "flag"), + ) + + val allowedEventTypes = JSONObject(config.toJSON()).getJSONArray("allowedEventTypes") + + assertEquals(3, allowedEventTypes.length()) + assertEquals("identify", allowedEventTypes.getString(0)) + assertEquals("screen", allowedEventTypes.getString(1)) + assertEquals("flag", allowedEventTypes.getString(2)) + } + + @Test + fun `serializes empty allowed event types`() { + val config = OptimizationConfig( + clientId = "test-client", + allowedEventTypes = emptyList(), + ) + + val allowedEventTypes = JSONObject(config.toJSON()).getJSONArray("allowedEventTypes") + + assertEquals(0, allowedEventTypes.length()) + } + } diff --git a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt index c93bb1c33..96220270c 100644 --- a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt +++ b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt @@ -193,6 +193,38 @@ class ViewTrackingControllerTest { cleanup(controller) } + @Test + fun `reevaluating after tracking becomes allowed starts a fresh visible cycle from last geometry`() = runTest { + val recorded = mutableListOf() + var trackingAllowed = false + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + isTrackingAllowed = { trackingAllowed }, + clock = { testScheduler.currentTime }, + ) + + controller.updateVisibility(0f, 100f, 0f, 200f) + advanceTimeBy(2_001L) + runCurrent() + + assertEquals(false, controller.isVisible) + assertTrue("expected no pre-consent trackView calls, got $recorded", recorded.isEmpty()) + + trackingAllowed = true + controller.reevaluateVisibility() + + assertEquals(true, controller.isVisible) + + advanceTimeBy(2_001L) + runCurrent() + + assertEquals("expected one post-consent current-visibility event", 1, recorded.size) + assertEquals(TEST_ENTRY_ID, recorded.single().componentId) + + cleanup(controller) + } + /** * Regression test pinning the current 0.8/0.8 symmetric threshold behavior. This was the * shape of the failure on the views CI x86_64 emulator: at t≈+1s the test's @@ -259,6 +291,7 @@ class ViewTrackingControllerTest { clock: () -> Long, entry: Map = mapOf("sys" to mapOf("id" to TEST_ENTRY_ID)), personalization: Map? = null, + isTrackingAllowed: () -> Boolean = { true }, threshold: Double = 0.8, viewTimeMs: Int = 2_000, viewDurationUpdateIntervalMs: Int = 5_000, @@ -270,6 +303,7 @@ class ViewTrackingControllerTest { entry = entry, personalization = personalization, onTrackView = onTrackView, + isTrackingAllowed = isTrackingAllowed, threshold = threshold, viewTimeMs = viewTimeMs, viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index 8a732b946..fe5fc0fd7 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -2,6 +2,11 @@ import Combine import Foundation import JavaScriptCore +struct EventEmissionResult { + let accepted: Bool + let data: [String: Any]? +} + /// The main public entry point for the Contentful Optimization SDK. /// /// `OptimizationClient` is an `ObservableObject` that wraps the JavaScript bridge @@ -172,6 +177,21 @@ public final class OptimizationClient: ObservableObject { } } + func screenWithEmissionResult(name: String, properties: [String: Any]? = nil) async throws -> EventEmissionResult { + let result = try await bridgeCallAsyncJSON(method: "screenWithEmissionResult") { + var payloadDict: [String: Any] = ["name": name] + if let properties = properties { + payloadDict["properties"] = properties + } + return try serializeJSON(payloadDict) + } + + return EventEmissionResult( + accepted: result?["accepted"] as? Bool ?? false, + data: result?["data"] as? [String: Any] + ) + } + /// Flush pending analytics and personalization events. public func flush() async throws { try await bridgeCallAsyncVoid(method: "flush", payload: "") @@ -318,6 +338,18 @@ public final class OptimizationClient: ObservableObject { return state } + /// Return whether Core would currently allow the named event method. + func hasConsent(method: String) -> Bool { + guard isInitialized else { return false } + let escaped = NativePolyfills.escapeForJS(method) + + guard let result = bridgeCallSyncWhenInitialized(method: "hasConsent", args: "'\(escaped)'"), + !result.isNull && !result.isUndefined + else { return false } + + return result.toBool() + } + // MARK: - Preview Panel /// Set the preview panel open state. diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift index 814b40818..0c38fe7f7 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift @@ -59,6 +59,7 @@ public struct OptimizationConfig { /// Default SDK locale used for Experience API requests and event context. public let locale: String? public var defaults: StorageDefaults? + public let allowedEventTypes: [String]? /// When `true`, the SDK emits detailed diagnostic logs via `os.Logger` /// under the subsystem `com.contentful.optimization`. @@ -72,7 +73,8 @@ public struct OptimizationConfig { insightsBaseUrl: String? = nil, locale: String? = nil, defaults: StorageDefaults? = nil, - debug: Bool = false + debug: Bool = false, + allowedEventTypes: [String]? = nil ) { self.clientId = clientId self.environment = environment @@ -81,6 +83,7 @@ public struct OptimizationConfig { self.locale = locale self.defaults = defaults self.debug = debug + self.allowedEventTypes = allowedEventTypes } /// Normalizes the SDK locale for Experience API requests and event context. @@ -107,6 +110,9 @@ public struct OptimizationConfig { if let locale = try normalizedLocale() { dict["locale"] = locale } + if let allowedEventTypes { + dict["allowedEventTypes"] = allowedEventTypes + } if defaults != nil || anonymousId != nil { var defaultsDict: [String: Any] = [:] if let consent = defaults?.consent { diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ScreenTrackingState.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ScreenTrackingState.swift new file mode 100644 index 000000000..d468854d1 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ScreenTrackingState.swift @@ -0,0 +1,29 @@ +struct ScreenTrackingState { + private var lastAcceptedScreenName: String? + private var inFlightScreenName: String? + + func shouldTrack(_ screenName: String, trackingAllowed: Bool) -> Bool { + trackingAllowed && + lastAcceptedScreenName != screenName && + inFlightScreenName != screenName + } + + mutating func markInFlight(_ screenName: String) { + inFlightScreenName = screenName + } + + mutating func markAccepted(_ screenName: String) { + lastAcceptedScreenName = screenName + } + + mutating func clearInFlight(_ screenName: String) { + if inFlightScreenName == screenName { + inFlightScreenName = nil + } + } + + mutating func reset() { + lastAcceptedScreenName = nil + inFlightScreenName = nil + } +} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift index 743c596f6..68bbbf3fb 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift @@ -13,13 +13,15 @@ struct TapTrackingModifier: ViewModifier { if enabled { content .simultaneousGesture(TapGesture().onEnded { - let metadata = TrackingMetadata(entry: entry, personalization: personalization) - let payload = TrackClickPayload( - componentId: metadata.componentId, - experienceId: metadata.experienceId, - variantIndex: metadata.variantIndex - ) - Task { try? await client.trackClick(payload) } + if client.hasConsent(method: "trackClick") { + let metadata = TrackingMetadata(entry: entry, personalization: personalization) + let payload = TrackClickPayload( + componentId: metadata.componentId, + experienceId: metadata.experienceId, + variantIndex: metadata.variantIndex + ) + Task { try? await client.trackClick(payload) } + } onTap?(entry) }) } else { diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift index ee6b1f75c..18d388ab0 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift @@ -116,6 +116,12 @@ public final class ViewTrackingController { scrollY: CGFloat, viewportHeight: CGFloat ) { + guard client?.hasConsent(method: "trackView") == true else { + if isVisible { + onBecameInvisible() + } + return + } guard elementHeight > 0 else { return } // Store for re-evaluation after resume @@ -230,6 +236,8 @@ public final class ViewTrackingController { private func emitEvent() { guard let client = client, let viewId = viewId else { return } + guard client.hasConsent(method: "trackView") else { return } + let payload = TrackViewPayload( componentId: metadata.componentId, viewId: viewId, diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingModifier.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingModifier.swift index ac5a530b1..0f65752f3 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingModifier.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingModifier.swift @@ -13,6 +13,7 @@ struct ViewTrackingModifier: ViewModifier { @Environment(\.scrollContext) private var scrollContext @State private var controller: ViewTrackingController? + @State private var lastFrame: CGRect? func body(content: Content) -> some View { if enabled { @@ -21,11 +22,10 @@ struct ViewTrackingModifier: ViewModifier { .onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .named(ScrollContext.coordinateSpaceName)) } action: { _, newFrame in - initControllerIfNeeded() performVisibilityCheck(frame: newFrame) } - .onAppear { - initControllerIfNeeded() + .onChange(of: client.state.consent) { _ in + performLastVisibilityCheck() } .onDisappear { controller?.onDisappear() @@ -42,6 +42,9 @@ struct ViewTrackingModifier: ViewModifier { .onChange(of: scrollContext) { _ in performVisibilityCheck(frame: geo.frame(in: .named(ScrollContext.coordinateSpaceName))) } + .onChange(of: client.state.consent) { _ in + performVisibilityCheck(frame: geo.frame(in: .named(ScrollContext.coordinateSpaceName))) + } } ) .onDisappear { @@ -54,6 +57,12 @@ struct ViewTrackingModifier: ViewModifier { } private func initControllerIfNeeded() { + guard client.hasConsent(method: "trackView") else { + controller?.onDisappear() + controller = nil + return + } + if controller == nil { controller = ViewTrackingController( client: client, @@ -67,6 +76,8 @@ struct ViewTrackingModifier: ViewModifier { } private func performVisibilityCheck(frame: CGRect) { + lastFrame = frame + initControllerIfNeeded() guard let controller = controller else { return } let vpHeight = scrollContext?.viewportHeight ?? 0 controller.updateVisibility( @@ -76,4 +87,10 @@ struct ViewTrackingModifier: ViewModifier { viewportHeight: vpHeight > 0 ? vpHeight : ViewTrackingController.fallbackViewportHeight ) } + + private func performLastVisibilityCheck() { + guard let lastFrame else { return } + + performVisibilityCheck(frame: lastFrame) + } } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift index fc0d41b5e..cb3b8c5d1 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/ScreenTrackingModifier.swift @@ -1,18 +1,40 @@ import SwiftUI -/// A `ViewModifier` that calls `client.screen(name:)` when the view appears. +/// A `ViewModifier` that emits a screen event when the view appears. public struct ScreenTrackingModifier: ViewModifier { let screenName: String @EnvironmentObject private var client: OptimizationClient + @State private var trackingState = ScreenTrackingState() public func body(content: Content) -> some View { content .onAppear { - Task { - try? await client.screen(name: screenName) - } + trackScreenIfAllowed() } + .onChange(of: client.state.consent) { _ in + trackScreenIfAllowed() + } + .onChange(of: screenName) { _ in + trackScreenIfAllowed() + } + } + + private func trackScreenIfAllowed() { + guard trackingState.shouldTrack( + screenName, + trackingAllowed: client.hasConsent(method: "screen") + ) else { return } + + let requestedScreenName = screenName + trackingState.markInFlight(requestedScreenName) + Task { + let result = try? await client.screenWithEmissionResult(name: requestedScreenName) + if result?.accepted == true { + trackingState.markAccepted(requestedScreenName) + } + trackingState.clearInFlight(requestedScreenName) + } } } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift index c8127a132..930206805 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift @@ -90,6 +90,32 @@ final class OptimizationClientTests: XCTestCase { XCTAssertNil(try config.normalizedLocale()) } + func testConfigToJSONSerializesAllowedEventTypes() throws { + let config = OptimizationConfig( + clientId: "test-client", + allowedEventTypes: ["identify", "screen", "flag"] + ) + + let json = try config.toJSON() + let data = json.data(using: .utf8)! + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(dict["allowedEventTypes"] as? [String], ["identify", "screen", "flag"]) + } + + func testConfigToJSONSerializesEmptyAllowedEventTypes() throws { + let config = OptimizationConfig( + clientId: "test-client", + allowedEventTypes: [] + ) + + let json = try config.toJSON() + let data = json.data(using: .utf8)! + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(dict["allowedEventTypes"] as? [String], []) + } + func testConfigToJSONRejectsInvalidLocale() throws { let config = OptimizationConfig( clientId: "test-client", @@ -1128,11 +1154,43 @@ final class OptimizationClientTests: XCTestCase { XCTAssertEqual(ScrollContext.coordinateSpaceName, "optimization-scroll") } + func testScreenTrackingStateTracksCurrentScreenOnceWhenAllowed() { + var state = ScreenTrackingState() + + XCTAssertFalse(state.shouldTrack("Home", trackingAllowed: false)) + XCTAssertTrue(state.shouldTrack("Home", trackingAllowed: true)) + + state.markAccepted("Home") + + XCTAssertFalse(state.shouldTrack("Home", trackingAllowed: true)) + XCTAssertTrue(state.shouldTrack("Details", trackingAllowed: true)) + } + + func testScreenTrackingStateSuppressesInFlightScreen() { + var state = ScreenTrackingState() + + XCTAssertTrue(state.shouldTrack("Home", trackingAllowed: true)) + state.markInFlight("Home") + XCTAssertFalse(state.shouldTrack("Home", trackingAllowed: true)) + state.clearInFlight("Home") + XCTAssertTrue(state.shouldTrack("Home", trackingAllowed: true)) + } + // MARK: - Phase 3: ViewTrackingController Tests @MainActor - func testViewTrackingControllerInitiallyInvisible() { + private func makeViewTrackingClient(consent: Bool = true) -> OptimizationClient { let client = OptimizationClient() + try! client.initialize(config: OptimizationConfig( + clientId: "test-client", + defaults: StorageDefaults(consent: consent) + )) + return client + } + + @MainActor + func testViewTrackingControllerInitiallyInvisible() { + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1146,7 +1204,7 @@ final class OptimizationClientTests: XCTestCase { @MainActor func testViewTrackingControllerBecomesVisibleAboveThreshold() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1165,7 +1223,7 @@ final class OptimizationClientTests: XCTestCase { @MainActor func testViewTrackingControllerStaysInvisibleBelowThreshold() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1182,9 +1240,27 @@ final class OptimizationClientTests: XCTestCase { XCTAssertFalse(controller.isVisible) } + @MainActor + func testViewTrackingControllerStaysInvisibleWithoutConsent() { + let client = makeViewTrackingClient(consent: false) + let controller = ViewTrackingController( + client: client, + entry: ["sys": ["id": "test"]], + personalization: nil, + threshold: 0.8, + viewTimeMs: 2000, + viewDurationUpdateIntervalMs: 5000 + ) + + controller.updateVisibility( + elementY: 0, elementHeight: 100, scrollY: 0, viewportHeight: 500 + ) + XCTAssertFalse(controller.isVisible) + } + @MainActor func testViewTrackingControllerBecomesInvisibleOnDisappear() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1205,7 +1281,7 @@ final class OptimizationClientTests: XCTestCase { @MainActor func testViewTrackingControllerResetsOnNewCycle() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1232,7 +1308,7 @@ final class OptimizationClientTests: XCTestCase { @MainActor func testViewTrackingControllerPauseAndResume() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1259,7 +1335,7 @@ final class OptimizationClientTests: XCTestCase { @MainActor func testViewTrackingControllerVisibilityWithPartialOverlap() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], @@ -1286,7 +1362,7 @@ final class OptimizationClientTests: XCTestCase { @MainActor func testViewTrackingControllerZeroHeightIgnored() { - let client = OptimizationClient() + let client = makeViewTrackingClient() let controller = ViewTrackingController( client: client, entry: ["sys": ["id": "test"]], diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 4269f5e54..1682beeb3 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -120,10 +120,10 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 27000, + "index.cjs": 27500, "index.mjs": 26500, - "preview-support.cjs": 6500, - "preview-support.mjs": 5500 + "preview-support.cjs": 1200, + "preview-support.mjs": 600 } } }, diff --git a/packages/react-native-sdk/src/components/OptimizationNavigationContainer.test.tsx b/packages/react-native-sdk/src/components/OptimizationNavigationContainer.test.tsx index b5b3848d1..1f170d37c 100644 --- a/packages/react-native-sdk/src/components/OptimizationNavigationContainer.test.tsx +++ b/packages/react-native-sdk/src/components/OptimizationNavigationContainer.test.tsx @@ -1,6 +1,12 @@ -import { describe, expect, it, rs } from '@rstest/core' +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core' +import React, { act, type ReactElement } from 'react' +import type { + NavigationContainerRef, + OptimizationNavigationContainerProps, +} from './OptimizationNavigationContainer' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) -// Mock React Native rs.mock('react-native', () => ({ Platform: { OS: 'ios' }, Dimensions: { @@ -10,7 +16,6 @@ rs.mock('react-native', () => ({ NativeModules: {}, })) -// Mock AsyncStorage rs.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: rs.fn(), @@ -19,7 +24,6 @@ rs.mock('@react-native-async-storage/async-storage', () => ({ }, })) -// Mock @contentful/optimization-core/logger rs.mock('@contentful/optimization-core/logger', () => ({ logger: { info: rs.fn(), @@ -37,22 +41,318 @@ rs.mock('@contentful/optimization-core/logger', () => ({ }), })) -// Mock useOptimization hook +interface MockScreenEmissionResult { + readonly accepted: boolean + readonly data?: unknown +} + +const mockScreenWithEmissionResult = rs + .fn<(payload: unknown) => Promise>() + .mockResolvedValue({ + accepted: true, + data: { profile: {}, changes: [], selectedOptimizations: [] }, + }) +const mockHasConsent = rs.fn(() => true) +const mockOptimization = { + hasConsent: mockHasConsent, +} + +let consentSnapshot: boolean | undefined = undefined + +rs.mock('@contentful/optimization-core/sdk-support', () => { + class AcceptedCurrentStateTracker { + private acceptedKey: string | undefined + private inFlightKey: string | undefined + + async emitIfNeeded({ + emit, + isAllowed, + key, + }: { + emit: () => Promise<{ accepted: boolean }> + isAllowed: boolean + key: string + }): Promise { + if (!isAllowed || this.acceptedKey === key || this.inFlightKey === key) { + return { accepted: false, attempted: false } + } + + this.inFlightKey = key + try { + const result = await emit() + if (result.accepted && this.inFlightKey === key) { + this.acceptedKey = key + } + return result + } finally { + if (this.inFlightKey === key) { + this.inFlightKey = undefined + } + } + } + } + + return { + AcceptedCurrentStateTracker, + screenWithEmissionResult: async (_sdk: unknown, payload: unknown) => + await mockScreenWithEmissionResult(payload), + } +}) + rs.mock('../context/OptimizationContext', () => ({ - useOptimization: () => ({ - screen: rs.fn().mockResolvedValue({ profile: {}, changes: [], selectedOptimizations: [] }), - }), + useOptimization: () => mockOptimization, +})) + +rs.mock('../hooks/useOptimizationConsentState', () => ({ + useOptimizationConsentState: () => consentSnapshot, })) +interface TestRenderer { + unmount: () => void + update: (element: ReactElement) => void +} + +interface TestRendererModule { + create: (element: ReactElement) => TestRenderer +} + +type NavigationRenderProps = Parameters[0] +type OptimizationNavigationContainerComponent = + React.ComponentType + +function isTestRendererModule(value: unknown): value is TestRendererModule { + if (typeof value !== 'object' || value === null) { + return false + } + + return typeof Reflect.get(value, 'create') === 'function' +} + +async function loadTestRenderer(): Promise { + const moduleName = 'react-test-renderer' + const testRendererModule: unknown = await import(moduleName) + + if (!isTestRendererModule(testRendererModule)) { + throw new Error('Expected react-test-renderer to expose create().') + } + + return testRendererModule +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function createDeferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolveDeferred: ((value: T) => void) | undefined + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + + return { + promise, + resolve: (value) => { + if (!resolveDeferred) { + throw new Error('Deferred promise was not initialized') + } + resolveDeferred(value) + }, + } +} + +function setCurrentRoute( + props: NavigationRenderProps, + route: { name: string; params?: Record }, +): void { + const ref = props.ref as { current: NavigationContainerRef | null } + ref.current = { + getCurrentRoute: () => route, + } +} + +function createContainerElement( + OptimizationNavigationContainer: OptimizationNavigationContainerComponent, + captureProps: (props: NavigationRenderProps) => void, + includeParams = false, +): ReactElement { + return ( + + {(props) => { + captureProps(props) + return null + }} + + ) +} + describe('OptimizationNavigationContainer', () => { - it('should export OptimizationNavigationContainer function', async () => { - const module = await import('./OptimizationNavigationContainer') - expect(typeof module.OptimizationNavigationContainer).toBe('function') + let renderer: TestRenderer | undefined = undefined + let latestProps: NavigationRenderProps | undefined = undefined + + void beforeEach(() => { + rs.clearAllMocks() + consentSnapshot = undefined + mockHasConsent.mockReturnValue(true) + mockScreenWithEmissionResult.mockResolvedValue({ + accepted: true, + data: { profile: {}, changes: [], selectedOptimizations: [] }, + }) + latestProps = undefined }) - it('should export OptimizationNavigationContainerProps type', async () => { - // Type check - this verifies the type exists at compile time - const module = await import('./OptimizationNavigationContainer') - expect(module).toBeDefined() + void afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount() + }) + renderer = undefined + } + }) + + function getProps(): NavigationRenderProps { + if (!latestProps) { + throw new Error('Expected navigation props to be captured') + } + + return latestProps + } + + async function renderContainer( + OptimizationNavigationContainer: OptimizationNavigationContainerComponent, + ): Promise { + const testRenderer = await loadTestRenderer() + + await act(async () => { + renderer = testRenderer.create( + createContainerElement(OptimizationNavigationContainer, (props) => { + latestProps = props + }), + ) + await flushPromises() + }) + } + + async function updateContainer( + OptimizationNavigationContainer: OptimizationNavigationContainerComponent, + ): Promise { + await act(async () => { + renderer?.update( + createContainerElement(OptimizationNavigationContainer, (props) => { + latestProps = props + }), + ) + await flushPromises() + }) + } + + it('builds distinct route keys only when params are included', async () => { + const { createScreenTrackingDescriptor } = await import('./OptimizationNavigationContainer') + + expect(createScreenTrackingDescriptor('Product', { id: '1' }, false)).toEqual({ + routeKey: 'Product', + properties: { name: 'Product' }, + }) + expect(createScreenTrackingDescriptor('Product', { id: '1' }, true)).toEqual({ + routeKey: 'Product:{"id":"1"}', + properties: { name: 'Product', params: { id: '1' } }, + }) + expect(createScreenTrackingDescriptor('Product', { id: '2' }, true).routeKey).toBe( + 'Product:{"id":"2"}', + ) + }) + + it('emits the current route once when screen tracking becomes allowed after ready', async () => { + const { OptimizationNavigationContainer } = await import('./OptimizationNavigationContainer') + + consentSnapshot = false + mockHasConsent.mockReturnValue(false) + await renderContainer(OptimizationNavigationContainer) + + setCurrentRoute(getProps(), { name: 'Home' }) + + await act(async () => { + getProps().onReady() + await flushPromises() + }) + + expect(mockScreenWithEmissionResult).not.toHaveBeenCalled() + + consentSnapshot = true + mockHasConsent.mockReturnValue(true) + await updateContainer(OptimizationNavigationContainer) + + expect(mockScreenWithEmissionResult).toHaveBeenCalledTimes(1) + expect(mockScreenWithEmissionResult).toHaveBeenCalledWith({ + name: 'Home', + properties: { name: 'Home' }, + screen: { name: 'Home' }, + }) + + consentSnapshot = undefined + await updateContainer(OptimizationNavigationContainer) + + expect(mockScreenWithEmissionResult).toHaveBeenCalledTimes(1) + }) + + it('retries the current route when the previous emission was not accepted', async () => { + const { OptimizationNavigationContainer } = await import('./OptimizationNavigationContainer') + + mockScreenWithEmissionResult + .mockResolvedValueOnce({ accepted: false, data: undefined }) + .mockResolvedValueOnce({ + accepted: true, + data: { profile: {}, changes: [], selectedOptimizations: [] }, + }) + + await renderContainer(OptimizationNavigationContainer) + setCurrentRoute(getProps(), { name: 'Home' }) + + await act(async () => { + getProps().onReady() + await flushPromises() + }) + + expect(mockScreenWithEmissionResult).toHaveBeenCalledTimes(1) + + consentSnapshot = true + await updateContainer(OptimizationNavigationContainer) + + expect(mockScreenWithEmissionResult).toHaveBeenCalledTimes(2) + }) + + it('does not duplicate a current route while its screen event is in flight', async () => { + const { OptimizationNavigationContainer } = await import('./OptimizationNavigationContainer') + const deferred = createDeferred<{ + accepted: boolean + data: { profile: object; changes: unknown[]; selectedOptimizations: unknown[] } + }>() + + mockScreenWithEmissionResult.mockReturnValueOnce(deferred.promise) + + await renderContainer(OptimizationNavigationContainer) + setCurrentRoute(getProps(), { name: 'Home' }) + + act(() => { + getProps().onReady() + }) + + expect(mockScreenWithEmissionResult).toHaveBeenCalledTimes(1) + + consentSnapshot = true + await updateContainer(OptimizationNavigationContainer) + + expect(mockScreenWithEmissionResult).toHaveBeenCalledTimes(1) + + await act(async () => { + deferred.resolve({ + accepted: true, + data: { profile: {}, changes: [], selectedOptimizations: [] }, + }) + await flushPromises() + }) }) }) diff --git a/packages/react-native-sdk/src/components/OptimizationNavigationContainer.tsx b/packages/react-native-sdk/src/components/OptimizationNavigationContainer.tsx index c583295c4..cb9cd84fd 100644 --- a/packages/react-native-sdk/src/components/OptimizationNavigationContainer.tsx +++ b/packages/react-native-sdk/src/components/OptimizationNavigationContainer.tsx @@ -1,8 +1,13 @@ import type { Properties } from '@contentful/optimization-core/api-schemas' +import { + AcceptedCurrentStateTracker, + screenWithEmissionResult, +} from '@contentful/optimization-core/sdk-support' import type React from 'react' -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import * as z from 'zod/mini' -import { useScreenTrackingCallback } from '../hooks/useScreenTracking' +import { useOptimization } from '../context/OptimizationContext' +import { useOptimizationConsentState } from '../hooks/useOptimizationConsentState' /** * @internal @@ -11,6 +16,32 @@ function paramsToJson(params: Record): z.core.util.JSONType { return z.json().parse(JSON.parse(JSON.stringify(params))) } +interface ScreenTrackingDescriptor { + readonly properties: Properties + readonly routeKey: string +} + +/** + * @internal + */ +export function createScreenTrackingDescriptor( + screenName: string, + params: Record | undefined, + includeParams: boolean, +): ScreenTrackingDescriptor { + const jsonParams = includeParams && params ? paramsToJson(params) : undefined + const routeKey = + jsonParams === undefined ? screenName : `${screenName}:${JSON.stringify(jsonParams)}` + + return { + routeKey, + properties: { + name: screenName, + ...(jsonParams === undefined ? {} : { params: jsonParams }), + }, + } +} + /** * @internal */ @@ -111,20 +142,34 @@ export function OptimizationNavigationContainer({ onReady: userOnReady, includeParams = false, }: OptimizationNavigationContainerProps): React.ReactNode { - const trackScreenView = useScreenTrackingCallback() + const contentfulOptimization = useOptimization() + const consent = useOptimizationConsentState(contentfulOptimization) const navigationRef = useRef(null) - const routeNameRef = useRef(undefined) + const routeKeyRef = useRef(undefined) + const screenTrackingStateRef = useRef(new AcceptedCurrentStateTracker()) const trackScreen = useCallback( (screenName: string, params?: Record) => { - const properties: Properties = { - name: screenName, - ...(includeParams && params ? { params: paramsToJson(params) } : {}), - } - - trackScreenView(screenName, properties) + const { properties, routeKey } = createScreenTrackingDescriptor( + screenName, + params, + includeParams, + ) + + void screenTrackingStateRef.current + .emitIfNeeded({ + key: routeKey, + isAllowed: contentfulOptimization.hasConsent('screen'), + emit: async () => + await screenWithEmissionResult(contentfulOptimization, { + name: screenName, + properties, + screen: { name: screenName }, + }), + }) + .catch(() => undefined) }, - [includeParams, trackScreenView], + [contentfulOptimization, includeParams], ) const handleReady = useCallback(() => { @@ -132,33 +177,55 @@ export function OptimizationNavigationContainer({ if (currentRoute) { const { name: initialRouteName, params } = currentRoute - routeNameRef.current = initialRouteName + if (contentfulOptimization.hasConsent('screen')) { + const { routeKey } = createScreenTrackingDescriptor(initialRouteName, params, includeParams) + routeKeyRef.current = routeKey + } trackScreen(initialRouteName, params) } userOnReady?.() - }, [trackScreen, userOnReady]) + }, [contentfulOptimization, includeParams, trackScreen, userOnReady]) const handleStateChange = useCallback( (state: NavigationState | undefined) => { - const { current: previousRouteName } = routeNameRef + const { current: previousRouteKey } = routeKeyRef const currentRoute = navigationRef.current?.getCurrentRoute() if (currentRoute) { const { name: currentRouteName, params } = currentRoute + if (!contentfulOptimization.hasConsent('screen')) { + routeKeyRef.current = undefined + userOnStateChange?.(state) + return + } - if (previousRouteName !== currentRouteName) { + const { routeKey: currentRouteKey } = createScreenTrackingDescriptor( + currentRouteName, + params, + includeParams, + ) + + if (previousRouteKey !== currentRouteKey) { trackScreen(currentRouteName, params) } - routeNameRef.current = currentRouteName + routeKeyRef.current = currentRouteKey } userOnStateChange?.(state) }, - [trackScreen, userOnStateChange], + [contentfulOptimization, includeParams, trackScreen, userOnStateChange], ) + useEffect(() => { + const currentRoute = navigationRef.current?.getCurrentRoute() + if (!currentRoute) return + + const { name, params } = currentRoute + trackScreen(name, params) + }, [consent, trackScreen]) + return children({ ref: navigationRef, onReady: handleReady, diff --git a/packages/react-native-sdk/src/hooks/useOptimizationConsentState.ts b/packages/react-native-sdk/src/hooks/useOptimizationConsentState.ts new file mode 100644 index 000000000..4d992de3c --- /dev/null +++ b/packages/react-native-sdk/src/hooks/useOptimizationConsentState.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import type ContentfulOptimization from '../ContentfulOptimization' + +/** + * Subscribe to Core consent so first-party automatic tracking can re-check + * allowlist-aware `hasConsent()` decisions when consent changes. + * + * @internal + */ +export function useOptimizationConsentState( + contentfulOptimization: ContentfulOptimization, +): boolean | undefined { + const [consent, setConsent] = useState(contentfulOptimization.states.consent.current) + + useEffect(() => { + const subscription = contentfulOptimization.states.consent.subscribe((value) => { + setConsent(value) + }) + + return () => { + subscription.unsubscribe() + } + }, [contentfulOptimization]) + + return consent +} diff --git a/packages/react-native-sdk/src/hooks/useScreenTracking.test.ts b/packages/react-native-sdk/src/hooks/useScreenTracking.test.ts index 8fdef0347..4a1e065e6 100644 --- a/packages/react-native-sdk/src/hooks/useScreenTracking.test.ts +++ b/packages/react-native-sdk/src/hooks/useScreenTracking.test.ts @@ -38,14 +38,55 @@ rs.mock('@contentful/optimization-core/logger', () => ({ })) // Create mock optimization instance -const mockScreen = rs - .fn() - .mockResolvedValue({ profile: {}, changes: [], selectedOptimizations: [] }) +interface MockScreenEmissionResult { + readonly accepted: boolean + readonly data?: unknown +} + +const mockScreenWithEmissionResult = rs + .fn<(payload: unknown) => Promise>() + .mockResolvedValue({ + accepted: true, + data: { profile: {}, changes: [], selectedOptimizations: [] }, + }) +const mockHasConsent = rs.fn(() => true) +const mockConsentObservable = { + current: undefined, + subscribe: rs.fn((next: (value: boolean | undefined) => void) => { + next(undefined) + return { unsubscribe: rs.fn() } + }), +} const mockOptimization = { - screen: mockScreen, + hasConsent: mockHasConsent, + states: { + consent: mockConsentObservable, + }, } +rs.mock('@contentful/optimization-core/sdk-support', () => { + class AcceptedCurrentStateTracker { + async emitIfNeeded({ + emit, + isAllowed, + }: { + emit: () => Promise + isAllowed: boolean + }): Promise { + if (!isAllowed) return { accepted: false, attempted: false } + + return await emit() + } + } + + return { + AcceptedCurrentStateTracker, + screenWithEmissionResult: async (_sdk: unknown, payload: unknown) => + await mockScreenWithEmissionResult(payload), + } +}) + // Mock useOptimization hook rs.mock('../context/OptimizationContext', () => ({ useOptimization: () => mockOptimization, @@ -55,8 +96,10 @@ rs.mock('../context/OptimizationContext', () => ({ const mockUseEffect = rs.fn() const mockUseCallback = rs.fn((fn: T): T => fn) const mockUseRef = rs.fn((initial: unknown) => ({ current: initial })) +const mockUseState = rs.fn((initial: unknown) => [initial, rs.fn()]) rs.mock('react', () => ({ + useState: (initial: unknown) => mockUseState(initial), useEffect: (fn: () => void) => { mockUseEffect(fn) fn() @@ -66,12 +109,20 @@ rs.mock('react', () => ({ return fn }, useRef: (initial: unknown) => mockUseRef(initial), + useSyncExternalStore: ( + _subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => unknown, + ) => getSnapshot(), })) describe('useScreenTracking', () => { beforeEach(() => { rs.clearAllMocks() - mockScreen.mockResolvedValue({ profile: {}, changes: [], selectedOptimizations: [] }) + mockHasConsent.mockReturnValue(true) + mockScreenWithEmissionResult.mockResolvedValue({ + accepted: true, + data: { profile: {}, changes: [], selectedOptimizations: [] }, + }) }) it('should track screen on mount when trackOnMount is true (default)', async () => { @@ -91,7 +142,7 @@ describe('useScreenTracking', () => { expect(mockUseEffect).toHaveBeenCalled() }) - it('should call sdk.screen with correct parameters', async () => { + it('should call sdk screen emission helper with correct parameters', async () => { const { useScreenTracking } = await import('./useScreenTracking') const { trackScreen } = useScreenTracking({ @@ -102,7 +153,7 @@ describe('useScreenTracking', () => { await trackScreen() - expect(mockScreen).toHaveBeenCalledWith({ + expect(mockScreenWithEmissionResult).toHaveBeenCalledWith({ name: 'TestScreen', properties: { customProp: 'value' }, screen: { name: 'TestScreen' }, @@ -119,7 +170,7 @@ describe('useScreenTracking', () => { await trackScreen() - expect(mockScreen).toHaveBeenCalledWith({ + expect(mockScreenWithEmissionResult).toHaveBeenCalledWith({ name: 'MinimalScreen', properties: {}, screen: { name: 'MinimalScreen' }, @@ -127,7 +178,7 @@ describe('useScreenTracking', () => { }) it('should return undefined on error', async () => { - mockScreen.mockRejectedValueOnce(new Error('Network error')) + mockScreenWithEmissionResult.mockRejectedValueOnce(new Error('Network error')) const { useScreenTracking } = await import('./useScreenTracking') @@ -147,7 +198,10 @@ describe('useScreenTracking', () => { changes: [], selectedOptimizations: [], } - mockScreen.mockResolvedValueOnce(expectedData) + mockScreenWithEmissionResult.mockResolvedValueOnce({ + accepted: true, + data: expectedData, + }) const { useScreenTracking } = await import('./useScreenTracking') @@ -160,4 +214,17 @@ describe('useScreenTracking', () => { expect(result).toEqual(expectedData) }) + + it('should skip automatic mount tracking until screen tracking is allowed', async () => { + mockHasConsent.mockReturnValue(false) + + const { useScreenTracking } = await import('./useScreenTracking') + + useScreenTracking({ + name: 'BlockedScreen', + }) + + expect(mockHasConsent).toHaveBeenCalledWith('screen') + expect(mockScreenWithEmissionResult).not.toHaveBeenCalled() + }) }) diff --git a/packages/react-native-sdk/src/hooks/useScreenTracking.ts b/packages/react-native-sdk/src/hooks/useScreenTracking.ts index e54066b58..22eb96041 100644 --- a/packages/react-native-sdk/src/hooks/useScreenTracking.ts +++ b/packages/react-native-sdk/src/hooks/useScreenTracking.ts @@ -1,7 +1,12 @@ import type { OptimizationData, Properties } from '@contentful/optimization-core/api-schemas' import { createScopedLogger } from '@contentful/optimization-core/logger' +import { + AcceptedCurrentStateTracker, + screenWithEmissionResult, +} from '@contentful/optimization-core/sdk-support' import { useCallback, useEffect, useRef } from 'react' import { useOptimization } from '../context/OptimizationContext' +import { useOptimizationConsentState } from './useOptimizationConsentState' const logger = createScopedLogger('RN:ScreenTracking') @@ -58,11 +63,8 @@ export function useScreenTrackingCallback(): (name: string, properties?: Propert optimizationRef.current = contentfulOptimization return useCallback((name: string, properties?: Properties) => { - const { current: currentOptimization } = optimizationRef - logger.info(`Tracking screen: "${name}"`) - - void currentOptimization.screen({ + void optimizationRef.current.screen({ name, properties: properties ?? EMPTY_PROPERTIES, screen: { name }, @@ -116,7 +118,8 @@ export function useScreenTracking({ trackOnMount = true, }: UseScreenTrackingOptions): UseScreenTrackingReturn { const contentfulOptimization = useOptimization() - const hasTrackedRef = useRef(false) + const consent = useOptimizationConsentState(contentfulOptimization) + const autoTrackingStateRef = useRef(new AcceptedCurrentStateTracker()) // Store contentfulOptimization in a ref to prevent unnecessary callback recreations const optimizationRef = useRef(contentfulOptimization) @@ -137,14 +140,13 @@ export function useScreenTracking({ logger.info(`Tracking screen: "${currentName}"`) try { - const result = await currentOptimization.screen({ + const result = await screenWithEmissionResult(currentOptimization, { name: currentName, properties: currentProperties, screen: { name: currentName }, }) - hasTrackedRef.current = true - return result + return result.data } catch (error) { logger.error(`Failed to track screen "${currentName}":`, error) return undefined @@ -153,15 +155,29 @@ export function useScreenTracking({ // Track on mount if enabled useEffect(() => { - if (trackOnMount && !hasTrackedRef.current) { - void trackScreen() + if (!trackOnMount) { + return } - }, [trackOnMount, trackScreen]) - // Reset tracking flag when name changes - useEffect(() => { - hasTrackedRef.current = false - }, [name]) + const { current: currentName } = nameRef + const { current: currentProperties } = propertiesRef + const { current: currentOptimization } = optimizationRef + + void autoTrackingStateRef.current + .emitIfNeeded({ + key: currentName, + isAllowed: currentOptimization.hasConsent('screen'), + emit: async () => + await screenWithEmissionResult(currentOptimization, { + name: currentName, + properties: currentProperties, + screen: { name: currentName }, + }), + }) + .catch((error: unknown) => { + logger.error(`Failed to track screen "${currentName}":`, error) + }) + }, [consent, contentfulOptimization, trackOnMount]) return { trackScreen } } diff --git a/packages/react-native-sdk/src/hooks/useTapTracking.test.ts b/packages/react-native-sdk/src/hooks/useTapTracking.test.ts new file mode 100644 index 000000000..00a72e480 --- /dev/null +++ b/packages/react-native-sdk/src/hooks/useTapTracking.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, rs } from '@rstest/core' +import type { Entry } from 'contentful' +import type { GestureResponderEvent } from 'react-native' + +rs.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Dimensions: { + get: rs.fn(() => ({ width: 375, height: 667 })), + addEventListener: rs.fn(() => ({ remove: rs.fn() })), + }, + AppState: { + addEventListener: rs.fn(() => ({ remove: rs.fn() })), + }, + NativeModules: {}, +})) + +rs.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: rs.fn(), + setItem: rs.fn(), + removeItem: rs.fn(), + }, +})) + +rs.mock('@contentful/optimization-core/logger', () => ({ + logger: { + info: rs.fn(), + debug: rs.fn(), + error: rs.fn(), + warn: rs.fn(), + }, + createScopedLogger: () => ({ + debug: rs.fn(), + info: rs.fn(), + log: rs.fn(), + warn: rs.fn(), + error: rs.fn(), + fatal: rs.fn(), + }), +})) + +rs.mock('../context/OptimizationScrollContext', () => ({ + useScrollContext: () => null, +})) + +const mockTrackClick = rs.fn().mockResolvedValue(undefined) +const mockHasConsent = rs.fn(() => true) +const mockConsentObservable = { + current: undefined, + subscribe: rs.fn((next: (value: boolean | undefined) => void) => { + next(undefined) + return { unsubscribe: rs.fn() } + }), +} + +rs.mock('../context/OptimizationContext', () => ({ + useOptimization: () => ({ + hasConsent: mockHasConsent, + states: { + consent: mockConsentObservable, + }, + trackClick: mockTrackClick, + }), +})) + +rs.mock('react', () => ({ + useState: (initial: unknown) => [initial, rs.fn()], + useEffect: (fn: () => undefined | (() => void)) => { + fn() + }, + useCallback: (fn: T): T => fn, + useRef: (initial: unknown) => ({ current: initial }), +})) + +function createMockEntry(id: string): Entry { + return { + // @ts-expect-error -- partial mock for testing, missing publishedVersion + sys: { + id, + type: 'Entry', + contentType: { sys: { id: 'testType', type: 'Link', linkType: 'ContentType' } }, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } }, + space: { sys: { id: 'space1', type: 'Link', linkType: 'Space' } }, + revision: 1, + locale: 'en-US', + }, + fields: { title: 'Test Entry' }, + metadata: { tags: [] }, + } +} + +function createTouchEvent(pageX: number, pageY: number): GestureResponderEvent { + return { + // @ts-expect-error -- partial native touch event mock for testing + nativeEvent: { + pageX, + pageY, + }, + } +} + +describe('useTapTracking', () => { + beforeEach(() => { + rs.clearAllMocks() + mockHasConsent.mockReturnValue(true) + }) + + it('should track tap payloads when trackClick is allowed', async () => { + const { useTapTracking } = await import('./useTapTracking') + const entry = createMockEntry('entry-clickable') + const onTap = rs.fn() + + const { onTouchStart, onTouchEnd } = useTapTracking({ + entry, + enabled: true, + onTap, + }) + + onTouchStart?.(createTouchEvent(10, 10)) + onTouchEnd?.(createTouchEvent(11, 11)) + + expect(mockTrackClick).toHaveBeenCalledWith({ + componentId: 'entry-clickable', + experienceId: undefined, + variantIndex: 0, + }) + expect(onTap).toHaveBeenCalledWith(entry) + }) + + it('should skip click payloads before trackClick is allowed but still invoke onTap', async () => { + mockHasConsent.mockReturnValue(false) + const { useTapTracking } = await import('./useTapTracking') + const entry = createMockEntry('entry-blocked') + const onTap = rs.fn() + + const { onTouchStart, onTouchEnd } = useTapTracking({ + entry, + enabled: true, + onTap, + }) + + onTouchStart?.(createTouchEvent(20, 20)) + onTouchEnd?.(createTouchEvent(20, 20)) + + expect(mockHasConsent).toHaveBeenCalledWith('trackClick') + expect(mockTrackClick).not.toHaveBeenCalled() + expect(onTap).toHaveBeenCalledWith(entry) + }) +}) diff --git a/packages/react-native-sdk/src/hooks/useTapTracking.ts b/packages/react-native-sdk/src/hooks/useTapTracking.ts index 97882211d..7260cf5b1 100644 --- a/packages/react-native-sdk/src/hooks/useTapTracking.ts +++ b/packages/react-native-sdk/src/hooks/useTapTracking.ts @@ -104,11 +104,6 @@ export function useTapTracking({ const touchStartRef = useRef<{ pageX: number; pageY: number } | null>(null) - const { componentId, experienceId, variantIndex } = extractTrackingMetadata( - entry, - selectedOptimization, - ) - const handleTouchStart = useCallback((e: GestureResponderEvent) => { const { nativeEvent: { pageX, pageY }, @@ -130,19 +125,26 @@ export function useTapTracking({ if (distance >= TAP_DISTANCE_THRESHOLD) return - logger.info(`Tap detected on ${componentId}, emitting entry tap event`) + if (optimizationRef.current.hasConsent('trackClick')) { + const { componentId, experienceId, variantIndex } = extractTrackingMetadata( + entry, + selectedOptimization, + ) - void optimizationRef.current.trackClick({ - componentId, - experienceId, - variantIndex, - }) + logger.info(`Tap detected on ${componentId}, emitting entry tap event`) + + void optimizationRef.current.trackClick({ + componentId, + experienceId, + variantIndex, + }) + } if (typeof onTap === 'function') { onTap(entry) } }, - [componentId, experienceId, variantIndex, entry, onTap], + [entry, onTap, selectedOptimization], ) if (!enabled) { diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts index 542567eb6..7fa0897c5 100644 --- a/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts @@ -41,8 +41,20 @@ rs.mock('@contentful/optimization-core/logger', () => ({ })) const mockTrackView = rs.fn().mockResolvedValue(undefined) +const mockHasConsent = rs.fn(() => true) +const mockConsentObservable = { + current: undefined, + subscribe: rs.fn((next: (value: boolean | undefined) => void) => { + next(undefined) + return { unsubscribe: rs.fn() } + }), +} const mockOptimization = { + hasConsent: mockHasConsent, + states: { + consent: mockConsentObservable, + }, trackView: mockTrackView, } @@ -80,6 +92,10 @@ rs.mock('react', () => ({ if (!ref) throw new Error('ref not found') return ref }, + useSyncExternalStore: ( + _subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => unknown, + ) => getSnapshot(), })) function resetHookState(): void { @@ -122,6 +138,7 @@ function getCallArg(callIndex: number): Record { describe('useViewportTracking', () => { beforeEach(() => { rs.clearAllMocks() + mockHasConsent.mockReturnValue(true) resetHookState() scrollContextValue = { scrollY: 0, viewportHeight: 800 } rs.useFakeTimers() @@ -172,6 +189,25 @@ describe('useViewportTracking', () => { expect(mockTrackView).not.toHaveBeenCalled() }) + + it('should not build or send view events before trackView is allowed', async () => { + mockHasConsent.mockReturnValue(false) + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-blocked') + + const { onLayout } = useViewportTracking({ + entry, + dwellTimeMs: 100, + minVisibleRatio: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(1000) + + expect(mockHasConsent).toHaveBeenCalledWith('trackView') + expect(mockTrackView).not.toHaveBeenCalled() + }) }) describe('periodic event scheduling', () => { diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.ts index 748232071..4aacaae4c 100644 --- a/packages/react-native-sdk/src/hooks/useViewportTracking.ts +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { AppState, Dimensions, type LayoutChangeEvent } from 'react-native' import { useOptimization } from '../context/OptimizationContext' import { useScrollContext } from '../context/OptimizationScrollContext' +import { useOptimizationConsentState } from './useOptimizationConsentState' const logger = createScopedLogger('RN:ViewportTracking') @@ -205,6 +206,8 @@ function getRemainingMsUntilNextFire( * A new visibility cycle (with a fresh `viewId`) starts each time the tracked * entry transitions from invisible to visible. Time accumulation pauses when * the app moves to the background. + * If `trackView` is blocked by consent, visibility timing starts only after + * Core allows the event type. * * @example * ```tsx @@ -234,14 +237,14 @@ export function useViewportTracking({ viewDurationUpdateIntervalMs = DEFAULT_VIEW_DURATION_UPDATE_INTERVAL_MS, }: UseViewportTrackingOptions): UseViewportTrackingReturn { const contentfulOptimization = useOptimization() + const consent = useOptimizationConsentState(contentfulOptimization) + const viewTrackingAllowed = contentfulOptimization.hasConsent('trackView') + const { + sys: { id: entryId }, + } = entry const scrollContext = useScrollContext() - const { componentId, experienceId, variantIndex, sticky } = extractTrackingMetadata( - entry, - selectedOptimization, - ) - const [screenHeight, setScreenHeight] = useState(Dimensions.get('window').height) useEffect(() => { @@ -263,37 +266,27 @@ export function useViewportTracking({ const optimizationRef = useRef(contentfulOptimization) optimizationRef.current = contentfulOptimization + const viewTrackingAllowedRef = useRef(viewTrackingAllowed) + viewTrackingAllowedRef.current = viewTrackingAllowed + const entryRef = useRef(entry) + entryRef.current = entry + const selectedOptimizationRef = useRef(selectedOptimization) + selectedOptimizationRef.current = selectedOptimization - const componentIdRef = useRef(componentId) - componentIdRef.current = componentId - const experienceIdRef = useRef(experienceId) - experienceIdRef.current = experienceId - const variantIndexRef = useRef(variantIndex) - variantIndexRef.current = variantIndex - const stickyRef = useRef(sticky) - stickyRef.current = sticky const stickySuccessRef = useRef(false) const stickyInFlightRef = useRef(false) - const stickyIdentityRef = useRef(null) - - const stickyIdentity = `${componentId}::${experienceId ?? ''}::${variantIndex}::${sticky === true ? '1' : '0'}` useEffect(() => { - if (stickyIdentityRef.current === stickyIdentity) return - - stickyIdentityRef.current = stickyIdentity stickySuccessRef.current = false stickyInFlightRef.current = false - }, [stickyIdentity]) + }, [entry, selectedOptimization]) - logger.debug( - `Hook initialized for ${componentId} (experienceId: ${experienceId}, variantIndex: ${variantIndex})`, - ) + logger.debug(`Hook initialized for ${entryId}`) useEffect(() => { - logger.debug(`Hook mounted/updated for ${componentId}`) + logger.debug(`Hook mounted/updated for ${entryId}`) return () => { - logger.debug(`Hook unmounting for ${componentId}`) + logger.debug(`Hook unmounting for ${entryId}`) } }, []) @@ -305,21 +298,27 @@ export function useViewportTracking({ }, []) const emitViewEvent = useCallback(() => { + if (!viewTrackingAllowedRef.current) return + const { current: cycle } = cycleRef const now = Date.now() flushAccumulatedTime(cycle, now) const viewId = cycle.viewId ?? createViewId() const durationMs = Math.max(0, Math.round(cycle.accumulatedMs)) + const { componentId, experienceId, variantIndex, sticky } = extractTrackingMetadata( + entryRef.current, + selectedOptimizationRef.current, + ) cycle.attempts += 1 logger.info( - `Emitting view event #${cycle.attempts} for ${componentIdRef.current} (viewDurationMs=${durationMs}, viewId=${viewId})`, + `Emitting view event #${cycle.attempts} for ${componentId} (viewDurationMs=${durationMs}, viewId=${viewId})`, ) const shouldSendSticky = - stickyRef.current === true && !stickySuccessRef.current && !stickyInFlightRef.current + sticky === true && !stickySuccessRef.current && !stickyInFlightRef.current if (shouldSendSticky) { stickyInFlightRef.current = true @@ -328,10 +327,10 @@ export function useViewportTracking({ void (async () => { try { const data = await optimizationRef.current.trackView({ - componentId: componentIdRef.current, + componentId, viewId, - experienceId: experienceIdRef.current, - variantIndex: variantIndexRef.current, + experienceId, + variantIndex, viewDurationMs: durationMs, sticky: shouldSendSticky ? true : undefined, }) @@ -340,10 +339,7 @@ export function useViewportTracking({ stickySuccessRef.current = true } } catch (error) { - logger.error( - `Failed to emit view event for ${componentIdRef.current} (viewId=${viewId})`, - error, - ) + logger.error(`Failed to emit view event for ${componentId} (viewId=${viewId})`, error) } finally { if (shouldSendSticky) { stickyInFlightRef.current = false @@ -376,7 +372,7 @@ export function useViewportTracking({ } logger.debug( - `Scheduling next fire for ${componentIdRef.current} in ${remainingMs}ms (attempt #${cycle.attempts + 1})`, + `Scheduling next fire for ${entryId} in ${remainingMs}ms (attempt #${cycle.attempts + 1})`, ) fireTimerRef.current = setTimeout(() => { @@ -389,7 +385,7 @@ export function useViewportTracking({ }, [clearFireTimer, dwellTimeMs, emitViewEvent, viewDurationUpdateIntervalMs]) const onVisibilityStart = useCallback(() => { - if (!enabled) return + if (!enabled || !viewTrackingAllowedRef.current) return const { current: cycle } = cycleRef const now = Date.now() @@ -398,7 +394,7 @@ export function useViewportTracking({ cycle.viewId = createViewId() cycle.visibleSince = now - logger.info(`Visibility cycle started for ${componentIdRef.current} (id=${cycle.viewId})`) + logger.info(`Visibility cycle started for ${entryId} (id=${cycle.viewId})`) scheduleNextFire() }, [enabled, scheduleNextFire]) @@ -411,14 +407,10 @@ export function useViewportTracking({ pauseAccumulation(cycle, now) if (cycle.viewId !== null && cycle.attempts > 0) { - logger.info( - `Visibility ended for ${componentIdRef.current} after ${cycle.attempts} events, emitting final`, - ) + logger.info(`Visibility ended for ${entryId} after ${cycle.attempts} events, emitting final`) emitViewEvent() } else { - logger.debug( - `Visibility ended for ${componentIdRef.current} before dwell requirement, no final event`, - ) + logger.debug(`Visibility ended for ${entryId} before dwell requirement, no final event`) } resetCycleState(cycle) @@ -427,7 +419,7 @@ export function useViewportTracking({ const canCheckVisibility = useCallback((): boolean => { const { current: dimensions } = dimensionsRef if (!dimensions) { - logger.debug(`${componentId} has no dimensions yet`) + logger.debug(`${entryId} has no dimensions yet`) return false } @@ -435,15 +427,15 @@ export function useViewportTracking({ const context = scrollContext ? '(waiting for ScrollView layout)' : '(waiting for screen dimensions)' - logger.debug(`${componentId} viewport height is 0 ${context}`) + logger.debug(`${entryId} viewport height is 0 ${context}`) return false } return true - }, [componentId, viewportHeight, scrollContext]) + }, [entryId, viewportHeight, scrollContext]) const checkVisibility = useCallback(() => { - logger.debug(`checkVisibility called for ${componentId}`) + logger.debug(`checkVisibility called for ${entryId}`) if (!canCheckVisibility()) { return @@ -467,36 +459,36 @@ export function useViewportTracking({ const contextType = scrollContext ? '(ScrollView)' : '(non-scrollable)' logger.debug( - `${componentId} visibility check ${contextType}: + `${entryId} visibility check ${contextType}: Element: y=${elementY.toFixed(0)}, bottom=${elementBottom.toFixed(0)} Viewport: scrollY=${scrollY.toFixed(0)}, height=${viewportHeight.toFixed(0)}, top=${viewportTop.toFixed(0)}, bottom=${viewportBottom.toFixed(0)} Visible: height=${visibleHeight.toFixed(0)}, ratio=${visibilityRatio.toFixed(2)}, minVisibleRatio=${minVisibleRatio}`, ) - const isNowVisible = visibilityRatio >= minVisibleRatio + const isNowVisible = viewTrackingAllowedRef.current && visibilityRatio >= minVisibleRatio const { current: wasVisible } = isVisibleRef isVisibleRef.current = isNowVisible if (isNowVisible && !wasVisible) { - logger.info(`${componentId} transitioned from invisible to visible`) + logger.info(`${entryId} transitioned from invisible to visible`) onVisibilityStart() } else if (!isNowVisible && wasVisible) { logger.info( - `${componentId} became invisible (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, + `${entryId} became invisible (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, ) onVisibilityEnd() } else if (!isNowVisible) { logger.debug( - `${componentId} is not visible enough (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, + `${entryId} is not visible enough (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, ) } else { logger.debug( - `${componentId} remains visible (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, + `${entryId} remains visible (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, ) } }, [ canCheckVisibility, - componentId, + entryId, minVisibleRatio, scrollY, viewportHeight, @@ -513,18 +505,18 @@ export function useViewportTracking({ }, } = event logger.debug( - `Layout for ${componentId}: y=${y}, height=${height} (position within ScrollView content)`, + `Layout for ${entryId}: y=${y}, height=${height} (position within ScrollView content)`, ) dimensionsRef.current = { y, height } checkVisibility() }, - [componentId, checkVisibility], + [entryId, checkVisibility], ) useEffect(() => { checkVisibility() - }, [scrollY, viewportHeight, checkVisibility]) + }, [consent, scrollY, viewportHeight, viewTrackingAllowed, checkVisibility]) useEffect(() => { const subscription = AppState.addEventListener('change', (nextState) => { @@ -537,7 +529,7 @@ export function useViewportTracking({ pauseAccumulation(cycle, now) if (cycle.attempts > 0) { - logger.info(`App backgrounded, emitting final event for ${componentIdRef.current}`) + logger.info(`App backgrounded, emitting final event for ${entryId}`) emitViewEvent() resetCycleState(cycle) isVisibleRef.current = false diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md index 40d029bd8..feda05a6f 100644 --- a/packages/universal/core-sdk/README.md +++ b/packages/universal/core-sdk/README.md @@ -211,6 +211,15 @@ optimization overrides, and map Contentful entries for local authoring workflows Application code must not use preview support directly unless it is building a first-party preview surface. +## SDK support + +SDK integration helpers live under the internal [`sdk-support`](./src/sdk-support/README.md) entry. +They are used by first-party runtime and framework SDKs to coordinate consent-aware automatic page +and screen tracking. + +Application code must not use SDK support directly. Use the public event methods documented by the +runtime SDK, such as `page()` and `screen()`. + ## Related - [Choosing the right SDK](https://contentful.github.io/optimization/documents/Documentation.Guides.choosing-the-right-sdk.html) - @@ -222,3 +231,4 @@ surface. - [Optimization React Native SDK](../../react-native-sdk/README.md) - mobile SDK built on `CoreStateful` - [Core preview support](./src/preview-support/README.md) - internal preview helper entry +- [Core SDK support](./src/sdk-support/README.md) - internal SDK helper entry diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index c22c86009..e09b28225 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -82,6 +82,16 @@ "default": "./dist/preview-support.cjs" } }, + "./sdk-support": { + "import": { + "types": "./dist/sdk-support.d.mts", + "default": "./dist/sdk-support.mjs" + }, + "require": { + "types": "./dist/sdk-support.d.cts", + "default": "./dist/sdk-support.cjs" + } + }, "./package.json": "./package.json" }, "files": [ @@ -92,9 +102,11 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 16000, - "index.mjs": 15000, - "preview-support.cjs": 6500, - "preview-support.mjs": 5500 + "index.mjs": 15500, + "preview-support.cjs": 5300, + "preview-support.mjs": 5500, + "sdk-support.cjs": 2000, + "sdk-support.mjs": 1500 } } }, diff --git a/packages/universal/core-sdk/rslib.config.ts b/packages/universal/core-sdk/rslib.config.ts index 836b88604..963e9e99b 100644 --- a/packages/universal/core-sdk/rslib.config.ts +++ b/packages/universal/core-sdk/rslib.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', 'preview-support': './src/preview-support/index.ts', + 'sdk-support': './src/sdk-support/index.ts', }, tsconfigPath: './tsconfig.build.json', decorators: { version: '2022-03' }, // stage-3 decorators diff --git a/packages/universal/core-sdk/src/CoreStateful.detached-states.test.ts b/packages/universal/core-sdk/src/CoreStateful.detached-states.test.ts index d679bc11a..95ad04e34 100644 --- a/packages/universal/core-sdk/src/CoreStateful.detached-states.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.detached-states.test.ts @@ -89,7 +89,17 @@ describe('CoreStateful detached states', () => { expect(flagValues).toEqual([undefined, true]) expect(flagOnceValues).toEqual([true]) expect(onceValues).toEqual([false]) - expect(trackFlagView).toHaveBeenCalledTimes(4) + expect(trackFlagView).toHaveBeenCalledTimes(2) + expect(trackFlagView).toHaveBeenNthCalledWith(1, { + componentId: 'dark-mode', + experienceId: undefined, + variantIndex: undefined, + }) + expect(trackFlagView).toHaveBeenNthCalledWith(2, { + componentId: 'dark-mode', + experienceId: 'experience-id', + variantIndex: 0, + }) canOptimizeSubscription.unsubscribe() flagSubscription.unsubscribe() diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index 8e41c5504..9ccc27f6b 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -6,6 +6,7 @@ import CoreStateful, { import type { ChangeArray } from './api-schemas' import type { TrackBuilderArgs, ViewBuilderArgs } from './events' import type { QueueFlushFailureContext } from './lib/queue' +import { pageWithEmissionResult, screenWithEmissionResult } from './sdk-support' import { batch, signalFns, signals } from './signals' import { PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, PREVIEW_PANEL_SIGNALS_SYMBOL } from './symbols' import { mergeTagEntry } from './test/fixtures/mergeTagEntry' @@ -40,6 +41,21 @@ const OTHER_FLAG_CHANGE: ChangeArray[number] = { const FLAG_CHANGES: ChangeArray = [DARK_MODE_CHANGE, OTHER_FLAG_CHANGE] +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolveDeferred: () => void = () => undefined + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + + return { promise, resolve: resolveDeferred } +} + class CoreStatefulTestHarness extends CoreStateful { getOnlineState(): boolean { return this.online @@ -548,6 +564,278 @@ describe('CoreStateful blocked event handling', () => { }) }) + it('does not let pre-consent getFlag retrievals suppress consented same-value tracking', () => { + const core = createCoreStateful({ + defaults: { + profile: profileFixture, + }, + }) + const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) + + signals.changes.value = FLAG_CHANGES + + expect(core.getFlag('dark-mode')).toBe(true) + expect(trackFlagView).not.toHaveBeenCalled() + + core.consent(true) + expect(core.getFlag('dark-mode')).toBe(true) + + expect(trackFlagView).toHaveBeenCalledTimes(1) + expect(trackFlagView).toHaveBeenCalledWith({ + componentId: 'dark-mode', + experienceId: 'experience-id', + variantIndex: 0, + }) + }) + + it('allows flag views without allowing entry views when the flag selector is allow-listed', async () => { + const blockedEvents: BlockedEvent[] = [] + const core = createCoreStateful({ + allowedEventTypes: ['flag'], + defaults: { + profile: profileFixture, + }, + onEventBlocked: (event) => blockedEvents.push(event), + }) + const events: unknown[] = [] + core.interceptors.event.add((event) => { + events.push(event) + return event + }) + + await core.trackView({ + componentId: 'entry-card', + viewDurationMs: 100, + viewId: 'entry-card-view', + }) + await core.trackFlagView({ componentId: 'dark-mode' }) + + expect(blockedEvents.map((event) => event.method)).toEqual(['trackView']) + expect(events).toEqual([ + expect.objectContaining({ + componentId: 'dark-mode', + componentType: 'Variable', + type: 'component', + }), + ]) + }) + + it('keeps component allow-list compatibility for entry views and flag views', async () => { + const blockedEvents: BlockedEvent[] = [] + const core = createCoreStateful({ + allowedEventTypes: ['component'], + defaults: { + profile: profileFixture, + }, + onEventBlocked: (event) => blockedEvents.push(event), + }) + const events: unknown[] = [] + core.interceptors.event.add((event) => { + events.push(event) + return event + }) + + await core.trackView({ + componentId: 'entry-card', + viewDurationMs: 100, + viewId: 'entry-card-view', + }) + await core.trackFlagView({ componentId: 'dark-mode' }) + + expect(blockedEvents).toEqual([]) + expect(events).toEqual([ + expect.objectContaining({ + componentId: 'entry-card', + componentType: 'Entry', + type: 'component', + }), + expect.objectContaining({ + componentId: 'dark-mode', + componentType: 'Variable', + type: 'component', + }), + ]) + }) + + it('does not let pre-profile getFlag retrievals suppress same-value tracking after profile is available', () => { + const core = createCoreStateful({ + defaults: { + consent: true, + }, + }) + const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) + + signals.changes.value = FLAG_CHANGES + + expect(core.getFlag('dark-mode')).toBe(true) + expect(trackFlagView).not.toHaveBeenCalled() + + signals.profile.value = profileFixture + expect(core.getFlag('dark-mode')).toBe(true) + + expect(trackFlagView).toHaveBeenCalledTimes(1) + }) + + it('tracks the same flag value again for a different profile', async () => { + const core = createCoreStateful({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) + + signals.changes.value = FLAG_CHANGES + + expect(core.getFlag('dark-mode')).toBe(true) + await flushMicrotasks() + + signals.profile.value = { ...profileFixture, id: 'profile-id-2' } + expect(core.getFlag('dark-mode')).toBe(true) + + expect(trackFlagView).toHaveBeenCalledTimes(2) + }) + + it('emits current active flag subscriptions when flag tracking becomes allowed without re-emitting values', async () => { + const core = createCoreStateful({ + defaults: { + profile: profileFixture, + }, + }) + const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) + const values: Array = [] + + signals.changes.value = FLAG_CHANGES + const subscription = core.states.flag('dark-mode').subscribe((value) => { + values.push(value === undefined ? undefined : Boolean(value)) + }) + + expect(values).toEqual([true]) + expect(trackFlagView).not.toHaveBeenCalled() + + core.consent(true) + + expect(values).toEqual([true]) + expect(trackFlagView).toHaveBeenCalledTimes(1) + expect(trackFlagView).toHaveBeenCalledWith({ + componentId: 'dark-mode', + experienceId: 'experience-id', + variantIndex: 0, + }) + + await flushMicrotasks() + core.consent(false) + core.consent(true) + + expect(trackFlagView).toHaveBeenCalledTimes(1) + + subscription.unsubscribe() + }) + + it('tracks a flag again when the accepted value changes away and back', async () => { + const core = createCoreStateful({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) + + signals.changes.value = FLAG_CHANGES + expect(core.getFlag('dark-mode')).toBe(true) + await flushMicrotasks() + + signals.changes.value = [ + { + ...DARK_MODE_CHANGE, + value: false, + }, + OTHER_FLAG_CHANGE, + ] + expect(core.getFlag('dark-mode')).toBe(false) + await flushMicrotasks() + + signals.changes.value = FLAG_CHANGES + expect(core.getFlag('dark-mode')).toBe(true) + + expect(trackFlagView).toHaveBeenCalledTimes(3) + }) + + it('dedupes accepted object flag values by deep equality', async () => { + const core = createCoreStateful({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) + + signals.changes.value = [ + { + key: 'object-flag', + type: 'Variable', + value: { a: 1, b: 2 }, + meta: { + experienceId: 'experience-id', + variantIndex: 0, + }, + }, + ] + expect(core.getFlag('object-flag')).toEqual({ a: 1, b: 2 }) + await flushMicrotasks() + + signals.changes.value = [ + { + key: 'object-flag', + type: 'Variable', + value: { b: 2, a: 1 }, + meta: { + experienceId: 'experience-id', + variantIndex: 0, + }, + }, + ] + expect(core.getFlag('object-flag')).toEqual({ b: 2, a: 1 }) + + expect(trackFlagView).toHaveBeenCalledTimes(1) + }) + + it('keeps the newest accepted flag tracking signature when older sends resolve later', async () => { + const core = createCoreStateful({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const firstSend = createDeferred() + const secondSend = createDeferred() + const trackFlagView = rs + .spyOn(core, 'trackFlagView') + .mockReturnValueOnce(firstSend.promise) + .mockReturnValueOnce(secondSend.promise) + .mockResolvedValue(undefined) + + signals.changes.value = FLAG_CHANGES + expect(core.getFlag('dark-mode')).toBe(true) + + signals.changes.value = [ + { + ...DARK_MODE_CHANGE, + value: false, + }, + OTHER_FLAG_CHANGE, + ] + expect(core.getFlag('dark-mode')).toBe(false) + + secondSend.resolve() + await flushMicrotasks() + firstSend.resolve() + await flushMicrotasks() + + expect(core.getFlag('dark-mode')).toBe(false) + expect(trackFlagView).toHaveBeenCalledTimes(2) + }) + it('exposes key-scoped flag observables and tracks distinct value retrievals', () => { const core = createCoreStateful({ defaults: { @@ -608,6 +896,45 @@ describe('CoreStateful blocked event handling', () => { subscription.unsubscribe() }) + it('reports accepted page emission results for offline queued events', async () => { + const core = createCoreStatefulHarness({ + allowedEventTypes: ['page'], + }) + + core.setOnlineState(false) + + await expect(pageWithEmissionResult(core, {})).resolves.toEqual({ accepted: true }) + }) + + it('reports blocked screen emission results without calling Experience', async () => { + const core = createCoreStateful() + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + changes: [], + selectedOptimizations: [], + profile: profileFixture, + }) + + await expect( + screenWithEmissionResult(core, { name: 'Home', properties: {}, screen: { name: 'Home' } }), + ).resolves.toEqual({ accepted: false }) + + expect(upsertProfile).not.toHaveBeenCalled() + }) + + it('returns Experience data for accepted screen emission results', async () => { + const core = createCoreStateful({ defaults: { consent: true } }) + const data = { + changes: [], + selectedOptimizations: [], + profile: profileFixture, + } + rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue(data) + + await expect( + screenWithEmissionResult(core, { name: 'Home', properties: {}, screen: { name: 'Home' } }), + ).resolves.toEqual({ accepted: true, data }) + }) + it('exposes preview panel states and preserves them on reset', () => { const core = createCoreStateful() const attachedValues: boolean[] = [] diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index c2cb28359..dd603a0f5 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -13,7 +13,7 @@ import type { ConsentController, ConsentGuard, ConsentInput } from './Consent' import type { CoreStatefulApiConfig } from './CoreApiConfig' import type { CoreConfig } from './CoreBase' import CoreStatefulEventEmitter from './CoreStatefulEventEmitter' -import { DEFAULT_ALLOWED_EVENT_TYPES, type EventType } from './EventType' +import { type AllowedEventType, DEFAULT_ALLOWED_EVENT_TYPES, type EventType } from './EventType' import { toPositiveInt } from './lib/number' import { type QueueFlushPolicy, resolveQueueFlushPolicy } from './lib/queue' import { @@ -23,6 +23,7 @@ import { import { normalizeExplicitLocale } from './locale' import { ExperienceQueue, type ExperienceQueueDropContext } from './queues/ExperienceQueue' import { InsightsQueue } from './queues/InsightsQueue' +import { installCoreStatefulSdkSupport } from './sdk-support/CoreStatefulSdkSupport' import { batch, blockedEvent as blockedEventSignal, @@ -52,7 +53,7 @@ import { PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, PREVIEW_PANEL_SIGNALS_SYMBOL } from '. const coreLogger = createScopedLogger('CoreStateful') const OFFLINE_QUEUE_MAX_EVENTS = 100 -export type { EventType } from './EventType' +export type { AllowedEventType, EventType } from './EventType' export type { ExperienceQueueDropContext } from './queues/ExperienceQueue' const hasDefinedValues = (record: Record): boolean => @@ -193,7 +194,7 @@ export interface CoreStatefulConfig extends CoreConfig { /** * Allow-listed event type strings permitted when consent is not set. */ - allowedEventTypes?: EventType[] + allowedEventTypes?: AllowedEventType[] /** Optional set of default values applied on initialization. */ defaults?: CoreConfigDefaults @@ -230,7 +231,7 @@ const OPTIMIZATION_UNLOCKING_EVENT_TYPES: readonly EventType[] = [ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController, ConsentGuard { private readonly singletonOwner: string private destroyed = false - protected readonly allowedEventTypes: EventType[] + protected readonly allowedEventTypes: AllowedEventType[] protected readonly experienceQueue: ExperienceQueue protected readonly insightsQueue: InsightsQueue protected readonly onEventBlocked?: CoreStatefulConfig['onEventBlocked'] @@ -303,6 +304,10 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController onOfflineDrop: resolvedQueuePolicy.onOfflineDrop, stateInterceptors: this.interceptors.state, }) + installCoreStatefulSdkSupport(this, { + pageWithEmissionResult: this.pageWithEmissionResult.bind(this), + screenWithEmissionResult: this.screenWithEmissionResult.bind(this), + }) batch(() => { consentSignal.value = defaultConsent @@ -320,6 +325,8 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController } private initializeEffects(): void { + this.initializeFlagViewConsentEffect() + effect(() => { coreLogger.debug( `Profile ${profileSignal.value && `with ID ${profileSignal.value.id}`} has been ${profileSignal.value ? 'set' : 'cleared'}`, diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index 7bb734563..0151ed5ce 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -15,7 +15,8 @@ import { isEqual } from 'es-toolkit/predicate' import type { BlockedEvent } from './BlockedEvent' import type { ConsentGuard } from './Consent' import CoreBase from './CoreBase' -import type { CoreStatefulConfig, EventType } from './CoreStateful' +import type { CoreStatefulConfig } from './CoreStateful' +import type { EventEmissionResult } from './EventEmissionResult' import type { ClickBuilderArgs, FlagViewBuilderArgs, @@ -26,6 +27,7 @@ import type { TrackBuilderArgs, ViewBuilderArgs, } from './events' +import type { AllowedEventType } from './EventType' import type { ExperienceQueue } from './queues/ExperienceQueue' import type { InsightsQueue } from './queues/InsightsQueue' import type { ResolvedData } from './resolvers' @@ -42,11 +44,24 @@ import { const coreLogger = createScopedLogger('CoreStateful') -const CONSENT_EVENT_TYPE_MAP: Readonly>> = { - trackView: 'component', - trackFlagView: 'component', - trackClick: 'component_click', - trackHover: 'component_hover', +const CONSENT_EVENT_TYPE_MAP: Readonly>> = { + trackView: ['component'], + trackFlagView: ['flag', 'component'], + trackClick: ['component_click'], + trackHover: ['component_hover'], +} + +type FlagViewTrackingSignature = readonly [ + value: Json, + componentId: string, + experienceId: string | undefined, + variantIndex: number | undefined, + profileId: string, +] + +interface AttemptedFlagViewTrackingSignature { + attemptId: number + signature: FlagViewTrackingSignature } /** @@ -60,24 +75,25 @@ abstract class CoreStatefulEventEmitter implements ConsentGuard { protected readonly flagObservables = new Map>() - private readonly lastTrackedFlagValues = new Map() - - protected abstract readonly allowedEventTypes: readonly string[] + private readonly lastAcceptedFlagViewSignatures = new Map< + string, + AttemptedFlagViewTrackingSignature + >() + private readonly pendingFlagViewSignatures = new Map< + string, + AttemptedFlagViewTrackingSignature[] + >() + private readonly activeFlagSubscriptionCounts = new Map() + private nextFlagViewTrackingAttemptId = 0 + + protected abstract readonly allowedEventTypes: readonly AllowedEventType[] protected abstract readonly experienceQueue: ExperienceQueue protected abstract readonly insightsQueue: InsightsQueue protected abstract readonly onEventBlocked?: CoreStatefulConfig['onEventBlocked'] override getFlag(name: string, changes: ChangeArray | undefined = changesSignal.value): Json { const value = super.getFlag(name, changes) - - if (!isEqual(value, this.lastTrackedFlagValues.get(name))) { - this.lastTrackedFlagValues.set(name, value) - const payload = this.buildFlagViewBuilderArgs(name, changes) - - void this.trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) - } + this.attemptFlagViewTracking(name, value, changes) return value } @@ -149,8 +165,25 @@ abstract class CoreStatefulEventEmitter async page( payload: PageViewBuilderArgs & { profile?: PartialProfile } = {}, ): Promise { + const { data } = await this.pageWithEmissionResult(payload) + return data + } + + /** + * Emit a page event and expose whether Core accepted it. + * + * @remarks + * Use this when coordinating automatic current-page tracking. A blocked + * event returns `{ accepted: false }`; an offline-queued but consent-allowed + * event returns `{ accepted: true }`. + * + * @internal + */ + protected async pageWithEmissionResult( + payload: PageViewBuilderArgs & { profile?: PartialProfile } = {}, + ): Promise { const { profile, ...builderArgs } = payload - return await this.sendExperienceEvent( + return await this.sendExperienceEventWithResult( 'page', [payload], this.eventBuilder.buildPageView(builderArgs), @@ -171,8 +204,25 @@ abstract class CoreStatefulEventEmitter async screen( payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, ): Promise { + const { data } = await this.screenWithEmissionResult(payload) + return data + } + + /** + * Emit a screen event and expose whether Core accepted it. + * + * @remarks + * Use this when coordinating automatic current-screen tracking. A blocked + * event returns `{ accepted: false }`; an offline-queued but consent-allowed + * event returns `{ accepted: true }`. + * + * @internal + */ + protected async screenWithEmissionResult( + payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, + ): Promise { const { profile, ...builderArgs } = payload - return await this.sendExperienceEvent( + return await this.sendExperienceEventWithResult( 'screen', [payload], this.eventBuilder.buildScreenView(builderArgs), @@ -288,9 +338,14 @@ abstract class CoreStatefulEventEmitter } hasConsent(name: string): boolean { - return ( - !!consentSignal.value || this.allowedEventTypes.includes(CONSENT_EVENT_TYPE_MAP[name] ?? name) - ) + if (consentSignal.value) return true + + const { [name]: mappedEventTypes } = CONSENT_EVENT_TYPE_MAP + if (mappedEventTypes !== undefined) { + return mappedEventTypes.some((eventType) => this.allowedEventTypes.includes(eventType)) + } + + return this.allowedEventTypes.some((eventType) => eventType === name) } onBlockedByConsent(name: string, args: readonly unknown[]): void { @@ -306,12 +361,25 @@ abstract class CoreStatefulEventEmitter event: ExperienceEventPayload, _profile?: PartialProfile, ): Promise { + const { data } = await this.sendExperienceEventWithResult(method, args, event, _profile) + return data + } + + protected async sendExperienceEventWithResult( + method: string, + args: readonly unknown[], + event: ExperienceEventPayload, + _profile?: PartialProfile, + ): Promise { if (!this.hasConsent(method)) { this.onBlockedByConsent(method, args) - return undefined + return { accepted: false } } - return await this.experienceQueue.send(event) + const data = await this.experienceQueue.send(event) + if (data === undefined) return { accepted: true } + + return { accepted: true, data } } protected async sendInsightsEvent( @@ -341,44 +409,151 @@ abstract class CoreStatefulEventEmitter } } + private buildFlagViewTrackingSignature( + value: Json, + payload: FlagViewBuilderArgs, + profileId: string, + ): FlagViewTrackingSignature { + return [value, payload.componentId, payload.experienceId, payload.variantIndex, profileId] + } + + protected initializeFlagViewConsentEffect(): void { + let wasReadyToTrack = this.hasConsent('trackFlagView') && profileSignal.value?.id !== undefined + let previousProfileId = profileSignal.value?.id + + signalFns.effect(() => { + const profileId = profileSignal.value?.id + const isReadyToTrack = this.hasConsent('trackFlagView') && profileId !== undefined + + if (isReadyToTrack && (!wasReadyToTrack || profileId !== previousProfileId)) { + this.trackActiveFlagSubscriptionViews() + } + + wasReadyToTrack = isReadyToTrack + previousProfileId = profileId + }) + } + + private attemptFlagViewTracking( + name: string, + value: Json, + changes: ChangeArray | undefined = changesSignal.value, + ): void { + const payload = this.buildFlagViewBuilderArgs(name, changes) + const profileId = profileSignal.value?.id + + if (!this.hasConsent('trackFlagView')) { + this.onBlockedByConsent('trackFlagView', [payload]) + return + } + + if (profileId === undefined) return + + const signature = this.buildFlagViewTrackingSignature(value, payload, profileId) + if (isEqual(this.lastAcceptedFlagViewSignatures.get(name)?.signature, signature)) { + return + } + + let pendingSignatures = this.pendingFlagViewSignatures.get(name) + if (pendingSignatures?.some((pending) => isEqual(pending.signature, signature)) === true) { + return + } + + pendingSignatures ??= [] + this.pendingFlagViewSignatures.set(name, pendingSignatures) + const pendingSignature = { + attemptId: this.nextFlagViewTrackingAttemptId++, + signature, + } + pendingSignatures.push(pendingSignature) + + void this.trackFlagView(payload) + .then(() => { + const lastAccepted = this.lastAcceptedFlagViewSignatures.get(name) + if (!lastAccepted || pendingSignature.attemptId > lastAccepted.attemptId) { + this.lastAcceptedFlagViewSignatures.set(name, pendingSignature) + } + }) + .catch((error: unknown) => { + logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) + }) + .finally(() => { + const pendingIndex = pendingSignatures.findIndex( + ({ attemptId }) => attemptId === pendingSignature.attemptId, + ) + if (pendingIndex !== -1) { + pendingSignatures.splice(pendingIndex, 1) + } + if (pendingSignatures.length === 0) { + this.pendingFlagViewSignatures.delete(name) + } + }) + } + + private trackActiveFlagSubscriptionViews(): void { + const { value: changes } = changesSignal + + for (const [name, count] of this.activeFlagSubscriptionCounts) { + if (count <= 0) continue + + this.attemptFlagViewTracking(name, super.getFlag(name, changes), changes) + } + } + + private registerActiveFlagSubscription(name: string): () => void { + this.activeFlagSubscriptionCounts.set( + name, + (this.activeFlagSubscriptionCounts.get(name) ?? 0) + 1, + ) + + return () => { + const nextCount = (this.activeFlagSubscriptionCounts.get(name) ?? 0) - 1 + + if (nextCount <= 0) { + this.activeFlagSubscriptionCounts.delete(name) + return + } + + this.activeFlagSubscriptionCounts.set(name, nextCount) + } + } + protected getFlagObservable(name: string): Observable { const existingObservable = this.flagObservables.get(name) if (existingObservable) return existingObservable - const trackFlagView = this.trackFlagView.bind(this) - const buildFlagViewBuilderArgs = this.buildFlagViewBuilderArgs.bind(this) + const attemptFlagViewTracking = this.attemptFlagViewTracking.bind(this) + const registerActiveFlagSubscription = this.registerActiveFlagSubscription.bind(this) const valueSignal = signalFns.computed(() => super.getFlag(name, changesSignal.value)) const distinctObservable = toDistinctObservable(valueSignal, isEqual) const trackedObservable: Observable = { get current() { const { current: value } = distinctObservable - const payload = buildFlagViewBuilderArgs(name, changesSignal.value) - void trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) + attemptFlagViewTracking(name, value, changesSignal.value) return value }, - subscribe: (next) => - distinctObservable.subscribe((value) => { - const payload = buildFlagViewBuilderArgs(name, changesSignal.value) - - void trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) + subscribe: (next) => { + const unregister = registerActiveFlagSubscription(name) + const subscription = distinctObservable.subscribe((value) => { + attemptFlagViewTracking(name, value, changesSignal.value) next(value) - }), + }) + + return { + unsubscribe: () => { + subscription.unsubscribe() + unregister() + }, + } + }, subscribeOnce: (next) => distinctObservable.subscribeOnce((value) => { - const payload = buildFlagViewBuilderArgs(name, changesSignal.value) - - void trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) + attemptFlagViewTracking(name, value, changesSignal.value) next(value) }), } diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts index 8e6b3ae23..e52e0ce8a 100644 --- a/packages/universal/core-sdk/src/CoreStateless.test.ts +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -298,6 +298,79 @@ describe('CoreStateless', () => { expect(upsertProfile.mock.calls[0]?.[0].events[0]?.context.gdpr.isConsentGiven).toBe(false) }) + it('uses the flag allow-list selector without allowing entry views before consent', async () => { + const blockedEvents: BlockedEvent[] = [] + const core = new CoreStateless({ + allowedEventTypes: ['flag'], + clientId: 'key_123', + environment: 'main', + onEventBlocked: (event) => blockedEvents.push(event), + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + const requestOptimization = core.forRequest({ + consent: false, + profile: { id: 'profile-123' }, + }) + + await requestOptimization.trackView({ + componentId: 'entry-card', + viewDurationMs: 100, + viewId: 'entry-card-view', + }) + await requestOptimization.trackFlagView({ componentId: 'dark-mode' }) + + expect(blockedEvents.map((event) => event.method)).toEqual(['trackView']) + expect(sendBatchEvents).toHaveBeenCalledWith([ + { + profile: { id: 'profile-123' }, + events: [ + expect.objectContaining({ + componentId: 'dark-mode', + componentType: 'Variable', + type: 'component', + }), + ], + }, + ]) + }) + + it('keeps component allow-list compatibility for stateless entry views and flag views', async () => { + const blockedEvents: BlockedEvent[] = [] + const core = new CoreStateless({ + allowedEventTypes: ['component'], + clientId: 'key_123', + environment: 'main', + onEventBlocked: (event) => blockedEvents.push(event), + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + const requestOptimization = core.forRequest({ + consent: false, + profile: { id: 'profile-123' }, + }) + + await requestOptimization.trackView({ + componentId: 'entry-card', + viewDurationMs: 100, + viewId: 'entry-card-view', + }) + await requestOptimization.trackFlagView({ componentId: 'dark-mode' }) + + expect(blockedEvents).toEqual([]) + expect(sendBatchEvents).toHaveBeenCalledTimes(2) + expect(sendBatchEvents.mock.calls.map(([events]) => events[0]?.events[0])).toEqual([ + expect.objectContaining({ + componentId: 'entry-card', + componentType: 'Entry', + type: 'component', + }), + expect.objectContaining({ + componentId: 'dark-mode', + componentType: 'Variable', + type: 'component', + }), + ]) + }) + it('updates the request-bound profile across sequential Experience calls', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) const firstProfile = { ...EMPTY_OPTIMIZATION_DATA.profile, id: 'first-profile' } diff --git a/packages/universal/core-sdk/src/CoreStateless.ts b/packages/universal/core-sdk/src/CoreStateless.ts index 3ae4cd153..4624b2513 100644 --- a/packages/universal/core-sdk/src/CoreStateless.ts +++ b/packages/universal/core-sdk/src/CoreStateless.ts @@ -7,7 +7,7 @@ import type { BlockedEvent } from './BlockedEvent' import type { CoreStatelessApiConfig } from './CoreApiConfig' import CoreBase, { type CoreConfig } from './CoreBase' import { CoreStatelessRequest, type CoreStatelessForRequestOptions } from './CoreStatelessRequest' -import { DEFAULT_ALLOWED_EVENT_TYPES, type EventType } from './EventType' +import { DEFAULT_ALLOWED_EVENT_TYPES, type AllowedEventType } from './EventType' import type { EventBuilderConfig } from './events' import { normalizeExplicitLocale } from './locale' @@ -55,7 +55,7 @@ export interface CoreStatelessConfig extends CoreConfig { /** * Allow-listed event type strings permitted when request event consent is not granted. */ - allowedEventTypes?: EventType[] + allowedEventTypes?: AllowedEventType[] /** * Callback invoked whenever an event call is blocked by consent. @@ -72,7 +72,7 @@ export type { StatelessNonStickyTrackViewPayload, StatelessStickyTrackViewPayload, } from './CoreStatelessRequest' -export type { EventType } from './EventType' +export type { AllowedEventType, EventType } from './EventType' const hasDefinedValues = (record: Record): boolean => Object.values(record).some((value) => value !== undefined) @@ -114,7 +114,7 @@ const createStatelessInsightsApiConfig = ( * host application, not the results of those calls. */ class CoreStateless extends CoreBase { - readonly allowedEventTypes: EventType[] + readonly allowedEventTypes: AllowedEventType[] readonly onEventBlocked?: CoreStatelessConfig['onEventBlocked'] constructor(config: CoreStatelessConfig) { diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.ts index c0a4af730..edc9981e3 100644 --- a/packages/universal/core-sdk/src/CoreStatelessRequest.ts +++ b/packages/universal/core-sdk/src/CoreStatelessRequest.ts @@ -9,7 +9,7 @@ import { import { createScopedLogger } from '@contentful/optimization-api-client/logger' import type CoreStateless from './CoreStateless' import type { CoreStatelessInsightsOptions, CoreStatelessRequestOptions } from './CoreStateless' -import type { EventType } from './EventType' +import type { AllowedEventType } from './EventType' import { PartialProfile, type OptimizationData } from './api-schemas' import type { ClickBuilderArgs, @@ -267,7 +267,7 @@ export class CoreStatelessRequest { } async trackFlagView(payload: StatelessInsightsPayload): Promise { - if (!this.hasConsent('component')) { + if (!this.hasConsent('flag', 'component')) { this.reportBlockedEvent('trackFlagView', [payload]) return } @@ -287,8 +287,11 @@ export class CoreStatelessRequest { } } - private hasConsent(eventType: EventType): boolean { - return this.requestEventConsent === true || this.core.allowedEventTypes.includes(eventType) + private hasConsent(...eventTypes: AllowedEventType[]): boolean { + return ( + this.requestEventConsent === true || + eventTypes.some((eventType) => this.core.allowedEventTypes.includes(eventType)) + ) } private async sendExperienceEvent( diff --git a/packages/universal/core-sdk/src/EventEmissionResult.ts b/packages/universal/core-sdk/src/EventEmissionResult.ts new file mode 100644 index 000000000..4f106dd75 --- /dev/null +++ b/packages/universal/core-sdk/src/EventEmissionResult.ts @@ -0,0 +1,12 @@ +import type { OptimizationData } from './api-schemas' + +/** + * Result returned by internal event emission helpers that need to distinguish + * accepted events from consent-blocked events. + * + * @internal + */ +export interface EventEmissionResult { + readonly accepted: boolean + readonly data?: TData +} diff --git a/packages/universal/core-sdk/src/EventType.ts b/packages/universal/core-sdk/src/EventType.ts index 144108605..3bbb0d51b 100644 --- a/packages/universal/core-sdk/src/EventType.ts +++ b/packages/universal/core-sdk/src/EventType.ts @@ -10,6 +10,17 @@ import type { */ export type EventType = InsightsEventType | ExperienceEventType +/** + * Event admission selectors accepted by `allowedEventTypes`. + * + * @remarks + * `EventType` values are API wire event types. Additional selector values, + * such as `flag`, narrow consent admission without changing emitted payloads. + * + * @public + */ +export type AllowedEventType = EventType | 'flag' + /** * Default Core event types allowed before event consent is granted. * diff --git a/packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.test.ts b/packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.test.ts new file mode 100644 index 000000000..bcd0f2b5b --- /dev/null +++ b/packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, rs } from '@rstest/core' +import { AcceptedCurrentStateTracker } from './AcceptedCurrentStateTracker' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolvePromise: (value: T) => void = () => undefined + let rejectPromise: (error: unknown) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + + return { promise, resolve: resolvePromise, reject: rejectPromise } +} + +describe('AcceptedCurrentStateTracker', () => { + it('marks accepted emissions and suppresses duplicate accepted keys', async () => { + const tracker = new AcceptedCurrentStateTracker() + const emit = rs.fn().mockResolvedValue({ accepted: true }) + + await expect(tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit })).resolves.toEqual({ + accepted: true, + attempted: true, + }) + await expect(tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit })).resolves.toEqual({ + accepted: false, + attempted: false, + }) + + expect(emit).toHaveBeenCalledTimes(1) + expect(tracker.hasAccepted()).toBe(true) + }) + + it('does not emit while the same key is already in flight', async () => { + const tracker = new AcceptedCurrentStateTracker() + const first = deferred<{ accepted: boolean }>() + const emit = rs.fn().mockReturnValue(first.promise) + + const firstEmission = tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit }) + await expect(tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit })).resolves.toEqual({ + accepted: false, + attempted: false, + }) + + first.resolve({ accepted: true }) + await firstEmission + + expect(emit).toHaveBeenCalledTimes(1) + }) + + it('retries after a blocked or not accepted result', async () => { + const tracker = new AcceptedCurrentStateTracker() + const emit = rs + .fn() + .mockResolvedValueOnce({ accepted: false }) + .mockResolvedValueOnce({ accepted: true }) + + await tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit }) + await tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit }) + + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('clears in-flight state after rejected emissions', async () => { + const tracker = new AcceptedCurrentStateTracker() + const emit = rs + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce({ accepted: true }) + + await expect(tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit })).rejects.toThrow( + 'network', + ) + await tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit }) + + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('tracks changed keys and supports reset', async () => { + const tracker = new AcceptedCurrentStateTracker() + const emit = rs.fn().mockResolvedValue({ accepted: true }) + + await tracker.emitIfNeeded({ key: 'home', isAllowed: true, emit }) + await tracker.emitIfNeeded({ key: 'details', isAllowed: true, emit }) + tracker.reset() + await tracker.emitIfNeeded({ key: 'details', isAllowed: true, emit }) + + expect(emit).toHaveBeenCalledTimes(3) + }) + + it('does not call emit when tracking is not allowed', async () => { + const tracker = new AcceptedCurrentStateTracker() + const emit = rs.fn().mockResolvedValue({ accepted: true }) + + await expect(tracker.emitIfNeeded({ key: 'home', isAllowed: false, emit })).resolves.toEqual({ + accepted: false, + attempted: false, + }) + + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.ts b/packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.ts new file mode 100644 index 000000000..88f93a866 --- /dev/null +++ b/packages/universal/core-sdk/src/sdk-support/AcceptedCurrentStateTracker.ts @@ -0,0 +1,91 @@ +import type { EventEmissionResult } from '../EventEmissionResult' + +export interface AcceptedCurrentStateTrackerOptions { + readonly isEqual?: (left: TKey, right: TKey) => boolean +} + +export interface AcceptedCurrentStateEmissionOptions { + readonly key: TKey + readonly isAllowed: boolean + readonly emit: () => Promise> +} + +export interface AcceptedCurrentStateEmissionResult extends EventEmissionResult { + readonly attempted: boolean +} + +/** + * Tracks accepted emissions for current-state SDK adapters such as the active + * page or screen. + * + * @public + */ +export class AcceptedCurrentStateTracker { + private readonly isEqual: (left: TKey, right: TKey) => boolean + private accepted: { key: TKey } | undefined + private inFlight: { key: TKey } | undefined + + constructor(options: AcceptedCurrentStateTrackerOptions = {}) { + this.isEqual = options.isEqual ?? Object.is + } + + hasAccepted(): boolean { + return this.accepted !== undefined + } + + shouldTrack(key: TKey, isAllowed: boolean): boolean { + return isAllowed && !this.matchesAcceptedKey(key) && !this.matchesInFlightKey(key) + } + + markInFlight(key: TKey): void { + this.inFlight = { key } + } + + markAccepted(key: TKey): void { + this.accepted = { key } + } + + clearInFlight(key: TKey): void { + if (this.matchesInFlightKey(key)) { + this.inFlight = undefined + } + } + + reset(): void { + this.accepted = undefined + this.inFlight = undefined + } + + async emitIfNeeded({ + key, + isAllowed, + emit, + }: AcceptedCurrentStateEmissionOptions): Promise< + AcceptedCurrentStateEmissionResult + > { + if (!this.shouldTrack(key, isAllowed)) { + return { accepted: false, attempted: false } + } + + this.markInFlight(key) + + try { + const result = await emit() + if (result.accepted && this.matchesInFlightKey(key)) { + this.markAccepted(key) + } + + return { ...result, attempted: true } + } finally { + this.clearInFlight(key) + } + } + + private matchesAcceptedKey(key: TKey): boolean { + return this.accepted !== undefined && this.isEqual(this.accepted.key, key) + } + + private matchesInFlightKey(key: TKey): boolean { + return this.inFlight !== undefined && this.isEqual(this.inFlight.key, key) + } +} diff --git a/packages/universal/core-sdk/src/sdk-support/CoreStatefulSdkSupport.ts b/packages/universal/core-sdk/src/sdk-support/CoreStatefulSdkSupport.ts new file mode 100644 index 000000000..ce135338e --- /dev/null +++ b/packages/universal/core-sdk/src/sdk-support/CoreStatefulSdkSupport.ts @@ -0,0 +1,47 @@ +import type { PartialProfile } from '../api-schemas' +import type { EventEmissionResult } from '../EventEmissionResult' +import type { PageViewBuilderArgs, ScreenViewBuilderArgs } from '../events' + +export interface CoreStatefulSdkSupport { + pageWithEmissionResult: ( + payload?: PageViewBuilderArgs & { profile?: PartialProfile }, + ) => Promise + screenWithEmissionResult: ( + payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, + ) => Promise +} + +const coreStatefulSdkSupportByInstance = new WeakMap() + +/** + * Register first-party SDK support access for a CoreStateful-compatible instance. + * + * @internal + */ +export function installCoreStatefulSdkSupport(core: object, support: CoreStatefulSdkSupport): void { + coreStatefulSdkSupportByInstance.set(core, support) +} + +function getCoreStatefulSdkSupport(core: object): CoreStatefulSdkSupport { + const support = coreStatefulSdkSupportByInstance.get(core) + + if (support === undefined) { + throw new Error('CoreStateful SDK support is unavailable for this instance.') + } + + return support +} + +export async function pageWithEmissionResult( + core: object, + payload: PageViewBuilderArgs & { profile?: PartialProfile } = {}, +): Promise { + return await getCoreStatefulSdkSupport(core).pageWithEmissionResult(payload) +} + +export async function screenWithEmissionResult( + core: object, + payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, +): Promise { + return await getCoreStatefulSdkSupport(core).screenWithEmissionResult(payload) +} diff --git a/packages/universal/core-sdk/src/sdk-support/README.md b/packages/universal/core-sdk/src/sdk-support/README.md new file mode 100644 index 000000000..c7627bfaa --- /dev/null +++ b/packages/universal/core-sdk/src/sdk-support/README.md @@ -0,0 +1,39 @@ +# SDK support (internal) + +> [!CAUTION] +> +> `@contentful/optimization-core/sdk-support` is internal first-party SDK infrastructure. It is not +> part of the application-facing Core SDK surface and can change without a SemVer major bump. +> Application integrations must use the public SDK methods documented by their platform SDK. + +This entry point ships helper contracts used by first-party runtime and framework SDKs that build on +Core. It owns acceptance-aware current-state tracking for automatic page and screen emitters, where +an adapter must distinguish a consent-blocked event from an accepted offline-queued event. + +## When to use this internal entry + +Use this entry only from first-party SDK packages or native bridge code that coordinates automatic +current-state tracking. Application code should call public SDK methods such as `page()` and +`screen()` instead. + +```ts +import { + AcceptedCurrentStateTracker, + pageWithEmissionResult, +} from '@contentful/optimization-core/sdk-support' +``` + +## Package surface + +| Surface | Purpose | +| ----------------------------- | --------------------------------------------------------------------- | +| `AcceptedCurrentStateTracker` | Dedupes accepted current-state emissions and retries blocked attempts | +| `pageWithEmissionResult` | Emits a page event and reports whether Core accepted it | +| `screenWithEmissionResult` | Emits a screen event and reports whether Core accepted it | +| `EventEmissionResult` | Shared accepted/data result contract for first-party SDK adapters | + +## What belongs elsewhere + +- Application-facing event methods stay on the runtime SDKs. +- Router, navigation, lifecycle, and viewport logic belongs in framework or runtime SDK packages. +- Preview-panel state management belongs in `@contentful/optimization-core/preview-support`. diff --git a/packages/universal/core-sdk/src/sdk-support/index.ts b/packages/universal/core-sdk/src/sdk-support/index.ts new file mode 100644 index 000000000..7c0a9c8de --- /dev/null +++ b/packages/universal/core-sdk/src/sdk-support/index.ts @@ -0,0 +1,8 @@ +export type { EventEmissionResult } from '../EventEmissionResult' +export { AcceptedCurrentStateTracker } from './AcceptedCurrentStateTracker' +export type { + AcceptedCurrentStateEmissionOptions, + AcceptedCurrentStateEmissionResult, + AcceptedCurrentStateTrackerOptions, +} from './AcceptedCurrentStateTracker' +export { pageWithEmissionResult, screenWithEmissionResult } from './CoreStatefulSdkSupport' diff --git a/packages/universal/optimization-js-bridge/src/index.ts b/packages/universal/optimization-js-bridge/src/index.ts index 6245f17bd..9a7d5899d 100644 --- a/packages/universal/optimization-js-bridge/src/index.ts +++ b/packages/universal/optimization-js-bridge/src/index.ts @@ -15,6 +15,7 @@ import { createExperienceDefinitions, createExperienceNameMap, } from '@contentful/optimization-core/preview-support' +import { screenWithEmissionResult } from '@contentful/optimization-core/sdk-support' import { computePreviewModel, transformOverrides } from './previewStateHelpers' type ResolveOptimizedEntryArgs = Parameters @@ -100,6 +101,7 @@ interface Bridge { ) => void getProfile: () => string | null getState: () => string + hasConsent: (method: string) => boolean destroy: () => void // Async with callbacks @@ -108,6 +110,11 @@ interface Bridge { onSuccess: (json: string) => void, onError: (error: string) => void, ) => void + screenWithEmissionResult: ( + payload: { name: string; properties?: ScreenProperties }, + onSuccess: (json: string) => void, + onError: (error: string) => void, + ) => void flush: (onSuccess: (json: string) => void, onError: (error: string) => void) => void trackView: ( payload: TrackViewPayload, @@ -302,6 +309,24 @@ const bridge: Bridge = { }) }, + screenWithEmissionResult(payload, onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + screenWithEmissionResult(instance, { + name: payload.name, + properties: payload.properties ?? {}, + }) + .then((result) => { + onSuccess(JSON.stringify(result)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + flush(onSuccess, onError) { if (!instance) { onError('SDK not initialized. Call initialize() first.') @@ -373,7 +398,8 @@ const bridge: Bridge = { flag(name: string) { if (!instance) return // Subscribing to the flag observable emits a `component` flag-view event - // through the core event stream (and again on each distinct value change). + // through the core event stream; one-off flag reads are not marked tracked + // until their flag-view event is actually accepted. flagSubscriptions.push(instance.states.flag(name).subscribe(() => undefined)) }, @@ -499,6 +525,10 @@ const bridge: Bridge = { return JSON.stringify(state) }, + hasConsent(method: string): boolean { + return instance?.hasConsent(method) ?? false + }, + destroy() { overrideManager?.destroy() overrideManager = null diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx index 03b9dd721..41afa62cf 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx @@ -1,6 +1,10 @@ import { rs } from '@rstest/core' import { StrictMode } from 'react' -import { createOptimizationSdk, renderWithOptimizationProviders } from '../test/sdkTestUtils' +import { + createMutableCloningObservable, + createOptimizationSdk, + renderWithOptimizationProviders, +} from '../test/sdkTestUtils' import type { AutoPagePayload } from './types' import { resetAutoPageEmitterState, useAutoPageEmitter } from './useAutoPageEmitter' @@ -30,7 +34,7 @@ describe('useAutoPageEmitter', () => { }) it('emits on first eligible render', async () => { - const page = rs.fn(async () => { + const page = rs.fn(async (_payload?: AutoPagePayload) => { await Promise.resolve() return undefined }) @@ -46,7 +50,7 @@ describe('useAutoPageEmitter', () => { }) it('emits on route changes', async () => { - const page = rs.fn(async () => { + const page = rs.fn(async (_payload?: AutoPagePayload) => { await Promise.resolve() return undefined }) @@ -82,6 +86,35 @@ describe('useAutoPageEmitter', () => { await second.unmount() }) + it('tracks identical route keys independently for different SDK instances', async () => { + const firstPage = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const secondPage = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const firstSdk = createOptimizationSdk({ page: firstPage }) + const secondSdk = createOptimizationSdk({ page: secondPage }) + const first = await renderWithOptimizationProviders( + , + firstSdk, + ) + + await first.unmount() + + const second = await renderWithOptimizationProviders( + , + secondSdk, + ) + + expect(firstPage).toHaveBeenCalledTimes(1) + expect(secondPage).toHaveBeenCalledTimes(1) + + await second.unmount() + }) + it('suppresses StrictMode double invocation for the same route key', async () => { const page = rs.fn(async () => { await Promise.resolve() @@ -193,4 +226,59 @@ describe('useAutoPageEmitter', () => { await rendered.unmount() }) + + it('skips sdk calls while page tracking is not allowed and emits the current route once allowed', async () => { + let pageTrackingAllowed = false + const consent = createMutableCloningObservable(undefined) + const page = rs.fn(async () => { + await Promise.resolve() + return undefined + }) + const sdk = createOptimizationSdk({ + hasConsent: () => pageTrackingAllowed, + page, + states: { + consent: consent.observable, + }, + }) + const rendered = await renderWithOptimizationProviders( + , + sdk, + ) + + expect(page).not.toHaveBeenCalled() + + pageTrackingAllowed = true + await consent.emit(true) + + expect(page).toHaveBeenCalledTimes(1) + expect(page).toHaveBeenCalledWith({}) + + await rendered.unmount() + }) + + it('treats offline queued page events as accepted for route deduplication', async () => { + const page = rs.fn(async (_payload?: AutoPagePayload) => { + await Promise.resolve() + return undefined + }) + const sdk = createOptimizationSdk({ + page, + }) + const first = await renderWithOptimizationProviders( + , + sdk, + ) + + await first.unmount() + + const second = await renderWithOptimizationProviders( + , + sdk, + ) + + expect(page).toHaveBeenCalledTimes(1) + + await second.unmount() + }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts index a2ef3e483..3f1289413 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts @@ -1,9 +1,12 @@ +import { + getCurrentPageTracker, + resetCurrentPageTrackerState, +} from '@contentful/optimization-web/sdk-support' import { useEffect } from 'react' import { useOptimization } from '../hooks/useOptimization' +import { useConsentState } from '../hooks/useOptimizationState' import type { AutoPagePayload } from './types' -let lastEmittedRouteKeyBySdk = new WeakMap() - export interface AutoPageEmissionMetadata { readonly isInitialEmission: boolean } @@ -45,25 +48,24 @@ export function useAutoPageEmitter({ buildPayload, }: UseAutoPageEmitterArgs): void { const sdk = useOptimization() + const consent = useConsentState() useEffect(() => { if (!enabled) { return } - const previousRouteKey = lastEmittedRouteKeyBySdk.get(sdk) - - if (previousRouteKey === routeKey) { - return - } - - const isInitialEmission = !lastEmittedRouteKeyBySdk.has(sdk) - lastEmittedRouteKeyBySdk.set(sdk, routeKey) + const tracker = getCurrentPageTracker(sdk) - void sdk.page(buildPayload({ isInitialEmission })) - }, [buildPayload, enabled, routeKey, sdk]) + void tracker + .emitIfNeeded(sdk, { + buildPayload, + routeKey, + }) + .catch(() => undefined) + }, [buildPayload, consent, enabled, routeKey, sdk]) } export function resetAutoPageEmitterState(): void { - lastEmittedRouteKeyBySdk = new WeakMap() + resetCurrentPageTrackerState() } diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx index 367b765ba..6651991f3 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -10,6 +10,7 @@ export type OptimizationSdk = Pick< | 'destroy' | 'getFlag' | 'getMergeTagValue' + | 'hasConsent' | 'identify' | 'locale' | 'page' diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx index 29a049b92..372cd517a 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx @@ -1,7 +1,7 @@ import { OPTIMIZED_ENTRY_HOST_DISPLAY, resolveOptimizedEntryNestingState, -} from '@contentful/optimization-web/presentation' +} from '@contentful/optimization-web/sdk-support' import type { Entry } from 'contentful' import { createContext, useContext, useEffect, useMemo, useRef, type JSX } from 'react' import { createScopedLogger } from '../logger' diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts index 1771548aa..95f085df1 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts @@ -1,4 +1,4 @@ -import type { OptimizedEntryLoadingTargetDisplay } from '@contentful/optimization-web/presentation' +import type { OptimizedEntryLoadingTargetDisplay } from '@contentful/optimization-web/sdk-support' import type { Entry } from 'contentful' import type { CSSProperties, ReactNode } from 'react' diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index 988354e7d..6b726f55e 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts @@ -3,7 +3,7 @@ import type { ResolvedData } from '@contentful/optimization-web/core-sdk' import { OptimizedEntryController, type OptimizedEntrySnapshot, -} from '@contentful/optimization-web/presentation' +} from '@contentful/optimization-web/sdk-support' import type { Entry, EntrySkeletonType } from 'contentful' import { useEffect, useMemo, useState } from 'react' import { useLiveUpdates } from '../hooks/useLiveUpdates' diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index ee78fca94..291dea63a 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -6,7 +6,7 @@ import { type OptimizationRootSdkConfig, type OnStatesReady as SharedOnStatesReady, type TrackEntryInteractionOptions as SharedTrackEntryInteractionOptions, -} from '@contentful/optimization-web/presentation' +} from '@contentful/optimization-web/sdk-support' import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index adab3ed38..034f85624 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -1,5 +1,6 @@ import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' import type { ExperienceRequestState, ResolvedData } from '@contentful/optimization-web/core-sdk' +import { installCurrentPageTrackerSdkSupport } from '@contentful/optimization-web/sdk-support' import type { Entry, EntrySkeletonType } from 'contentful' import type { ReactElement, ReactNode } from 'react' import { act } from 'react' @@ -123,21 +124,25 @@ export function createOptimizableTestEntry(id: string): TestEntry { export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): OptimizationSdk { const { states: stateOverrides, tracking: trackingOverrides, ...sdkOverrides } = overrides + const page = + sdkOverrides.page ?? + (async () => { + await Promise.resolve() + return undefined + }) - return { + const sdk: OptimizationSdk = { consent: () => undefined, destroy: () => undefined, getFlag: () => undefined, getMergeTagValue: () => undefined, + hasConsent: () => true, identify: async () => { await Promise.resolve() return undefined }, locale: undefined, - page: async () => { - await Promise.resolve() - return undefined - }, + page, resolveOptimizedEntry: (entry: Entry) => ({ entry }), reset: () => undefined, setLocale: () => undefined, @@ -179,6 +184,17 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): }, ...sdkOverrides, } + + installCurrentPageTrackerSdkSupport(sdk, { + pageWithEmissionResult: async (payload: Parameters[0]) => { + const data = await page(payload) + if (data === undefined) return { accepted: true } + + return { accepted: true, data } + }, + }) + + return sdk } export function createRuntime( diff --git a/packages/web/preview-panel/package.json b/packages/web/preview-panel/package.json index fc7318a01..9c4bea998 100644 --- a/packages/web/preview-panel/package.json +++ b/packages/web/preview-panel/package.json @@ -51,9 +51,9 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "contentful-optimization-web-preview-panel.umd.js": 38500, - "index.cjs": 27500, - "index.mjs": 26500 + "contentful-optimization-web-preview-panel.umd.js": 37500, + "index.cjs": 25000, + "index.mjs": 24600 } } }, diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index e6d1cb539..100f9b22e 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -62,6 +62,16 @@ "default": "./dist/presentation.cjs" } }, + "./sdk-support": { + "import": { + "types": "./dist/sdk-support.d.mts", + "default": "./dist/sdk-support.mjs" + }, + "require": { + "types": "./dist/sdk-support.d.cts", + "default": "./dist/sdk-support.cjs" + } + }, "./web-components": { "import": { "types": "./dist/web-components.d.mts", @@ -110,12 +120,14 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "contentful-optimization-web.umd.js": 33000, - "index.cjs": 11600, + "contentful-optimization-web.umd.js": 33500, + "index.cjs": 11700, "index.mjs": 11700, - "contentful-optimization-web-components.umd.js": 36400, + "contentful-optimization-web-components.umd.js": 38000, "presentation.cjs": 2650, "presentation.mjs": 2250, + "sdk-support.cjs": 3300, + "sdk-support.mjs": 2800, "web-components.cjs": 15200, "web-components.mjs": 16150 } diff --git a/packages/web/web-sdk/rslib.config.ts b/packages/web/web-sdk/rslib.config.ts index 1abbf41df..ca6fad7bc 100644 --- a/packages/web/web-sdk/rslib.config.ts +++ b/packages/web/web-sdk/rslib.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ constants: './src/constants.ts', symbols: './src/symbols.ts', presentation: './src/presentation/index.ts', + 'sdk-support': './src/sdk-support/index.ts', 'web-components': './src/web-components/index.ts', 'core-sdk': './src/core-sdk.ts', 'api-client': './src/api-client.ts', @@ -96,6 +97,7 @@ export default defineConfig({ constants: './src/constants.ts', symbols: './src/symbols.ts', presentation: './src/presentation/index.ts', + 'sdk-support': './src/sdk-support/index.ts', 'web-components': './src/web-components/index.ts', 'core-sdk': './src/core-sdk.ts', 'api-client': './src/api-client.ts', diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 027a75943..7b8562356 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -336,7 +336,7 @@ class ContentfulOptimization extends CoreStateful { consent: { value }, } = signals - this.entryInteractionRuntime.syncAutoTrackedEntryInteractions(!!value) + this.entryInteractionRuntime.syncAutoTrackedEntryInteractions() LocalStore.consent = value }) diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts index af150a070..8ddbe1f37 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts @@ -36,7 +36,10 @@ const createDetectorMocks = (): DetectorMocks< clearElement: rs.fn(), }) -function createRuntime(autoTrack?: { clicks?: boolean; hovers?: boolean; views?: boolean }): { +function createRuntime( + autoTrack?: { clicks?: boolean; hovers?: boolean; views?: boolean }, + hasConsent: (name: string) => boolean = () => true, +): { runtime: EntryInteractionRuntime clickDetector: DetectorMocks hoverDetector: DetectorMocks< @@ -69,6 +72,7 @@ function createRuntime(autoTrack?: { clicks?: boolean; hovers?: boolean; views?: trackClick: rs.fn().mockResolvedValue(undefined), trackHover: rs.fn().mockResolvedValue(undefined), trackView: rs.fn().mockResolvedValue(undefined), + hasConsent: rs.fn(hasConsent), } const clickDetector = createDetectorMocks() const hoverDetector = createDetectorMocks< @@ -208,23 +212,47 @@ describe('EntryInteractionRuntime', () => { }) it('syncs auto-tracked interactions with consent state', () => { - const { clickDetector, hoverDetector, runtime, viewDetector } = createRuntime({ - clicks: true, - hovers: true, - views: true, - }) - - runtime.syncAutoTrackedEntryInteractions(true) + let allowed = true + const { clickDetector, hoverDetector, runtime, viewDetector } = createRuntime( + { + clicks: true, + hovers: true, + views: true, + }, + () => allowed, + ) + + runtime.syncAutoTrackedEntryInteractions() expect(clickDetector.start).toHaveBeenCalledTimes(1) expect(hoverDetector.start).toHaveBeenCalledTimes(1) expect(viewDetector.start).toHaveBeenCalledTimes(1) - runtime.syncAutoTrackedEntryInteractions(false) + allowed = false + runtime.syncAutoTrackedEntryInteractions() expect(clickDetector.stop).toHaveBeenCalledTimes(1) expect(hoverDetector.stop).toHaveBeenCalledTimes(1) expect(viewDetector.stop).toHaveBeenCalledTimes(1) }) + it('keeps forced element interactions stopped until their event type is allowed', () => { + let allowed = false + const { clickDetector, runtime } = createRuntime( + { + clicks: false, + }, + (name) => name !== 'trackClick' || allowed, + ) + const element = document.createElement('div') + + runtime.tracking.enableElement('clicks', element, { data: { entryId: 'entry-1' } }) + expect(clickDetector.start).not.toHaveBeenCalled() + + allowed = true + runtime.syncAutoTrackedEntryInteractions() + expect(clickDetector.start).toHaveBeenCalledTimes(1) + expect(clickDetector.setAuto).toHaveBeenCalledWith(false) + }) + it('does not start or stop interactions when auto-tracking is disabled', () => { const { clickDetector, hoverDetector, runtime, viewDetector } = createRuntime({ clicks: false, @@ -232,8 +260,8 @@ describe('EntryInteractionRuntime', () => { views: false, }) - runtime.syncAutoTrackedEntryInteractions(true) - runtime.syncAutoTrackedEntryInteractions(false) + runtime.syncAutoTrackedEntryInteractions() + runtime.syncAutoTrackedEntryInteractions() expect(clickDetector.start).not.toHaveBeenCalled() expect(hoverDetector.start).not.toHaveBeenCalled() diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts index 67fa199ef..b0a77fcff 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts @@ -31,7 +31,15 @@ const ENTRY_INTERACTIONS: EntryInteraction[] = ['clicks', 'views', 'hovers'] type EntryInteractionRuntimeCore = EntryClickTrackingCore & EntryViewTrackingCore & - EntryHoverTrackingCore + EntryHoverTrackingCore & { + hasConsent: (name: string) => boolean + } + +const ENTRY_INTERACTION_CONSENT_METHODS: Readonly> = { + clicks: 'trackClick', + views: 'trackView', + hovers: 'trackHover', +} interface EntryInteractionElementOverride { enabled: boolean @@ -62,10 +70,13 @@ interface EntryInteractionDetectorMap { * @remarks * Owns shared registry/observer dependencies and exposes an imperative * tracking API that can enable, disable, observe, and unobserve interactions. + * Automatic view and hover timers start only after Core allows the underlying + * event type by consent or `allowedEventTypes`. * * @internal */ export class EntryInteractionRuntime { + private readonly core: EntryInteractionRuntimeCore private readonly entryInteractionDetectors: EntryInteractionDetectorMap private readonly entryElementRegistry: EntryElementRegistry private readonly entryExistenceObserver: ElementExistenceObserver @@ -97,12 +108,12 @@ export class EntryInteractionRuntime { views: false, hovers: false, } - private autoTrackingAllowed = true public constructor( core: EntryInteractionRuntimeCore, autoTrackEntryInteraction?: AutoTrackEntryInteractionOptions, ) { + this.core = core this.entryExistenceObserver = new ElementExistenceObserver() this.entryElementRegistry = new EntryElementRegistry(this.entryExistenceObserver) @@ -148,8 +159,7 @@ export class EntryInteractionRuntime { this.entryExistenceObserver.disconnect() } - public syncAutoTrackedEntryInteractions(hasConsent: boolean): void { - this.autoTrackingAllowed = hasConsent + public syncAutoTrackedEntryInteractions(): void { this.reconcileAllInteractions() } @@ -160,8 +170,10 @@ export class EntryInteractionRuntime { } private reconcileInteraction(interaction: EntryInteraction, restart = false): void { - const shouldAutoTrack = this.autoTrack[interaction] && this.autoTrackingAllowed - const shouldRun = shouldAutoTrack || this.hasEnabledElementOverrides(interaction) + const shouldAutoTrack = this.isAutoTrackingInteractionEnabled(interaction) + const shouldRun = + this.isInteractionAllowed(interaction) && + (shouldAutoTrack || this.hasEnabledElementOverrides(interaction)) if (!shouldRun) { if (this.isInteractionRunning[interaction]) { @@ -278,6 +290,12 @@ export class EntryInteractionRuntime { return false } + private isAutoTrackingInteractionEnabled(interaction: EntryInteraction): boolean { + if (interaction === 'clicks') return this.autoTrack.clicks + if (interaction === 'views') return this.autoTrack.views + return this.autoTrack.hovers + } + private applyElementOverrides(interaction: EntryElementInteraction): void { const detector = this.getDetector(interaction) const overrides = this.getElementOverrides(interaction) @@ -304,6 +322,10 @@ export class EntryInteractionRuntime { return this.elementOverrides[interaction] } + private isInteractionAllowed(interaction: EntryInteraction): boolean { + return this.core.hasConsent(ENTRY_INTERACTION_CONSENT_METHODS[interaction]) + } + private enableTracking( interaction: TInteraction, options?: EntryInteractionStartOptions, diff --git a/packages/web/web-sdk/src/sdk-support/currentPageTracker.test.ts b/packages/web/web-sdk/src/sdk-support/currentPageTracker.test.ts new file mode 100644 index 000000000..e553dc02c --- /dev/null +++ b/packages/web/web-sdk/src/sdk-support/currentPageTracker.test.ts @@ -0,0 +1,168 @@ +import type { EventEmissionResult } from '@contentful/optimization-core/sdk-support' +import { beforeEach, describe, expect, it, rs } from '@rstest/core' +import { + CurrentPageTracker, + getCurrentPageTracker, + installCurrentPageTrackerSdkSupport, + resetCurrentPageTrackerState, +} from './currentPageTracker' + +function createSdk({ + hasConsent = () => true, + pageWithEmissionResult, +}: { + hasConsent?: (name: string) => boolean + pageWithEmissionResult: (payload: unknown) => Promise +}): { hasConsent: (name: string) => boolean } { + const sdk = { hasConsent } + + installCurrentPageTrackerSdkSupport(sdk, { + pageWithEmissionResult, + }) + + return sdk +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolvePromise: (value: T) => void = () => undefined + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + return { promise, resolve: resolvePromise } +} + +describe('CurrentPageTracker', () => { + beforeEach(() => { + resetCurrentPageTrackerState() + }) + + it('emits the first allowed page and dedupes the accepted route key', async () => { + const pageWithEmissionResult = rs.fn().mockResolvedValue({ accepted: true }) + const sdk = createSdk({ pageWithEmissionResult }) + const tracker = new CurrentPageTracker() + + await tracker.emitIfNeeded(sdk, { + routeKey: '/home', + buildPayload: ({ isInitialEmission }) => ({ properties: { isInitialEmission } }), + }) + await tracker.emitIfNeeded(sdk, { + routeKey: '/home', + buildPayload: ({ isInitialEmission }) => ({ properties: { isInitialEmission } }), + }) + + expect(pageWithEmissionResult).toHaveBeenCalledTimes(1) + expect(pageWithEmissionResult).toHaveBeenCalledWith({ + properties: { isInitialEmission: true }, + }) + }) + + it('retries the current route after tracking becomes allowed', async () => { + let isAllowed = false + const pageWithEmissionResult = rs.fn().mockResolvedValue({ accepted: true }) + const sdk = createSdk({ + hasConsent: () => isAllowed, + pageWithEmissionResult, + }) + const tracker = new CurrentPageTracker() + + await tracker.emitIfNeeded(sdk, { + routeKey: '/blocked', + buildPayload: () => ({}), + }) + isAllowed = true + await tracker.emitIfNeeded(sdk, { + routeKey: '/blocked', + buildPayload: () => ({}), + }) + + expect(pageWithEmissionResult).toHaveBeenCalledTimes(1) + }) + + it('does not duplicate the same route while in flight', async () => { + const first = deferred<{ accepted: boolean }>() + const pageWithEmissionResult = rs.fn().mockReturnValueOnce(first.promise) + const sdk = createSdk({ pageWithEmissionResult }) + const tracker = new CurrentPageTracker() + + const firstEmission = tracker.emitIfNeeded(sdk, { + routeKey: '/slow', + buildPayload: () => ({}), + }) + await tracker.emitIfNeeded(sdk, { + routeKey: '/slow', + buildPayload: () => ({}), + }) + first.resolve({ accepted: true }) + await firstEmission + + expect(pageWithEmissionResult).toHaveBeenCalledTimes(1) + }) + + it('emits changed routes and updates initial emission metadata after acceptance', async () => { + const pageWithEmissionResult = rs.fn().mockResolvedValue({ accepted: true }) + const sdk = createSdk({ pageWithEmissionResult }) + const tracker = new CurrentPageTracker() + + await tracker.emitIfNeeded(sdk, { + routeKey: '/first', + buildPayload: ({ isInitialEmission }) => ({ properties: { isInitialEmission } }), + }) + await tracker.emitIfNeeded(sdk, { + routeKey: '/second', + buildPayload: ({ isInitialEmission }) => ({ properties: { isInitialEmission } }), + }) + + expect(pageWithEmissionResult).toHaveBeenNthCalledWith(1, { + properties: { isInitialEmission: true }, + }) + expect(pageWithEmissionResult).toHaveBeenNthCalledWith(2, { + properties: { isInitialEmission: false }, + }) + }) + + it('cleans up rejected emissions so the route can retry', async () => { + const pageWithEmissionResult = rs + .fn() + .mockRejectedValueOnce(new Error('failed')) + .mockResolvedValueOnce({ accepted: true }) + const sdk = createSdk({ pageWithEmissionResult }) + const tracker = new CurrentPageTracker() + + await tracker + .emitIfNeeded(sdk, { + routeKey: '/retry', + buildPayload: () => ({}), + }) + .catch(() => undefined) + await tracker.emitIfNeeded(sdk, { + routeKey: '/retry', + buildPayload: () => ({}), + }) + + expect(pageWithEmissionResult).toHaveBeenCalledTimes(2) + }) + + it('returns the current tracker instance for an SDK', () => { + const sdk = createSdk({ pageWithEmissionResult: rs.fn() }) + + expect(getCurrentPageTracker(sdk)).toBe(getCurrentPageTracker(sdk)) + }) + + it('tracks current route state independently per SDK', async () => { + const pageWithEmissionResult = rs.fn().mockResolvedValue({ accepted: true }) + const firstSdk = createSdk({ pageWithEmissionResult }) + const secondSdk = createSdk({ pageWithEmissionResult }) + + await getCurrentPageTracker(firstSdk).emitIfNeeded(firstSdk, { + routeKey: '/home', + buildPayload: () => ({}), + }) + await getCurrentPageTracker(secondSdk).emitIfNeeded(secondSdk, { + routeKey: '/home', + buildPayload: () => ({}), + }) + + expect(pageWithEmissionResult).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/web/web-sdk/src/sdk-support/currentPageTracker.ts b/packages/web/web-sdk/src/sdk-support/currentPageTracker.ts new file mode 100644 index 000000000..68c6c3e88 --- /dev/null +++ b/packages/web/web-sdk/src/sdk-support/currentPageTracker.ts @@ -0,0 +1,80 @@ +import type { PageViewBuilderArgs } from '@contentful/optimization-core' +import type { EventEmissionResult } from '@contentful/optimization-core/sdk-support' +import { + AcceptedCurrentStateTracker, + pageWithEmissionResult, +} from '@contentful/optimization-core/sdk-support' + +export interface CurrentPageEmissionMetadata { + readonly isInitialEmission: boolean +} + +export interface CurrentPageTrackerSdk { + readonly hasConsent: (name: string) => boolean +} + +export interface CurrentPageTrackerSdkSupport { + readonly pageWithEmissionResult: (payload?: PageViewBuilderArgs) => Promise +} + +export interface EmitCurrentPageOptions { + readonly buildPayload: (metadata: CurrentPageEmissionMetadata) => PageViewBuilderArgs | undefined + readonly routeKey: string +} + +export class CurrentPageTracker { + private readonly tracker = new AcceptedCurrentStateTracker() + + async emitIfNeeded( + sdk: CurrentPageTrackerSdk, + { buildPayload, routeKey }: EmitCurrentPageOptions, + ): Promise { + const isInitialEmission = !this.tracker.hasAccepted() + + await this.tracker.emitIfNeeded({ + key: routeKey, + isAllowed: sdk.hasConsent('page'), + emit: async () => { + const payload = buildPayload({ isInitialEmission }) ?? {} + + const support = currentPageTrackerSdkSupportBySdk.get(sdk) + + if (support) { + return await support.pageWithEmissionResult(payload) + } + + return await pageWithEmissionResult(sdk, payload) + }, + }) + } + + reset(): void { + this.tracker.reset() + } +} + +let currentPageTrackerBySdk = new WeakMap() +let currentPageTrackerSdkSupportBySdk = new WeakMap() + +export function getCurrentPageTracker(sdk: object): CurrentPageTracker { + let tracker = currentPageTrackerBySdk.get(sdk) + + if (tracker === undefined) { + tracker = new CurrentPageTracker() + currentPageTrackerBySdk.set(sdk, tracker) + } + + return tracker +} + +export function resetCurrentPageTrackerState(): void { + currentPageTrackerBySdk = new WeakMap() + currentPageTrackerSdkSupportBySdk = new WeakMap() +} + +export function installCurrentPageTrackerSdkSupport( + sdk: object, + support: CurrentPageTrackerSdkSupport, +): void { + currentPageTrackerSdkSupportBySdk.set(sdk, support) +} diff --git a/packages/web/web-sdk/src/sdk-support/index.ts b/packages/web/web-sdk/src/sdk-support/index.ts new file mode 100644 index 000000000..aa95c9c2e --- /dev/null +++ b/packages/web/web-sdk/src/sdk-support/index.ts @@ -0,0 +1,33 @@ +export { + OPTIMIZED_ENTRY_HOST_DISPLAY, + OptimizedEntryController, + createOptimizationRootSdkBinding, + disposeOptimizationRootSdkBinding, + resolveOptimizedEntryNestingState, + resolveTrackEntryInteractionOptions, + type CreateInjectedOptimizationRootSdkBindingOptions, + type CreateOwnedOptimizationRootSdkBindingOptions, + type OnStatesReady, + type OptimizationRootSdk, + type OptimizationRootSdkBinding, + type OptimizationRootSdkConfig, + type OptimizedEntryControllerOptions, + type OptimizedEntryLoadingTargetDisplay, + type OptimizedEntryNestingState, + type OptimizedEntrySdk, + type OptimizedEntrySnapshot, + type OptimizedEntrySnapshotListener, + type TrackEntryInteractionOptions, +} from '../presentation' +export { + CurrentPageTracker, + getCurrentPageTracker, + installCurrentPageTrackerSdkSupport, + resetCurrentPageTrackerState, +} from './currentPageTracker' +export type { + CurrentPageEmissionMetadata, + CurrentPageTrackerSdk, + CurrentPageTrackerSdkSupport, + EmitCurrentPageOptions, +} from './currentPageTracker'