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'