From dc0963cb09e0a737fecc4854cf7d5faba040e84a Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Thu, 12 Mar 2026 19:16:40 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A1=20refactor(flags):=20Harden=20flag?= =?UTF-8?q?=20access=20to=20key-scoped=20APIs=20and=20align=20flag-view=20?= =?UTF-8?q?tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bulk flag consumer access with key-scoped APIs: - Rename `getCustomFlag` to `getFlag` - Remove consumer-facing `getCustomFlags/getFlags` - Replace `states.flags` with `states.flag(name)` in stateful runtimes - Preserve advanced full-map resolution via `flagsResolver.resolve(changes)` - Make automatic flag-view emission stateful-only: - `CoreStateful.getFlag(...)` and `states.flag(name)` emit `trackFlagView` - Stateless/base `getFlag(...)` does not auto-emit - Add `FlagViewBuilderArgs` and update `trackFlagView` signatures across core/analytics - Make `viewId` and `viewDurationMs` optional for flag-view-compatible `ViewEvent` payloads - Remove exported `signals.flags` and add `toDistinctObservable(...)` for distinct key-scoped flag streams - Update downstream harnesses/implementations (node/web demos, React hook typing) to match new surfaces - Added/updated E2E tests to cover flag view events - Refresh TSDoc/README/CONTRIBUTING and SpecKit specs/research for current pre-release behavior BREAKING CHANGE: - `getCustomFlag` -> `getFlag` - `getCustomFlags/getFlags` removed from consumer-facing SDK APIs - `states.flags` -> `states.flag(name)` [[NT-2600](https://contentful.atlassian.net/browse/NT-2600)] --- .lintstagedrc.yaml | 2 +- .specify/research/repository-research.md | 7 +- CONTRIBUTING.md | 7 + .../e2e/entry-view-tracking.spec.ts} | 6 +- .../e2e/flag-view-tracking.spec.ts | 33 +++++ .../node-sdk+web-sdk/src/index.ejs | 8 + implementations/node-sdk/src/app.ts | 5 +- implementations/react-native-sdk/App.tsx | 21 +++ .../e2e/flag-view-tracking.test.js | 22 +++ .../web-sdk/e2e/entry-view-tracking.spec.ts | 101 +++++++++++++ .../web-sdk/e2e/flag-view-tracking.spec.ts | 34 +++++ implementations/web-sdk/public/index.html | 5 +- .../e2e/flag-view-tracking.spec.ts | 44 ++++++ implementations/web-sdk_react/src/App.tsx | 60 ++++---- .../hooks/useOptimizationState.ts | 8 +- packages/node/node-sdk/README.md | 23 +-- packages/node/node-sdk/index.ejs | 8 - packages/node/node-sdk/server.ts | 5 +- .../src/experience/event/ViewEvent.ts | 4 +- packages/universal/core-sdk/README.md | 30 ++-- .../universal/core-sdk/src/CoreBase.test.ts | 22 ++- packages/universal/core-sdk/src/CoreBase.ts | 26 +--- .../core-sdk/src/CoreStateful.test.ts | 107 +++++++++++++- .../universal/core-sdk/src/CoreStateful.ts | 139 ++++++++++++++---- .../core-sdk/src/analytics/AnalyticsBase.ts | 9 +- .../src/analytics/AnalyticsStateful.ts | 9 +- .../src/analytics/AnalyticsStateless.ts | 10 +- .../core-sdk/src/events/EventBuilder.ts | 24 ++- .../personalization/PersonalizationBase.ts | 29 +--- .../PersonalizationStateful.test.ts | 108 +++++++++----- .../PersonalizationStateful.ts | 42 +++--- .../core-sdk/src/personalization/index.ts | 7 +- .../core-sdk/src/signals/Observable.ts | 45 ++++++ .../universal/core-sdk/src/signals/signals.ts | 12 -- packages/web/preview-panel/index.html | 17 +-- packages/web/web-sdk/README.md | 26 ++-- packages/web/web-sdk/index.html | 17 +-- .../009-core-foundational-and-shared/spec.md | 10 +- .../spec.md | 10 +- .../spec.md | 25 ++-- .../spec.md | 4 +- 41 files changed, 811 insertions(+), 320 deletions(-) rename implementations/{web-sdk/e2e/events.spec.ts => node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts} (95%) create mode 100644 implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts create mode 100644 implementations/react-native-sdk/e2e/flag-view-tracking.test.js create mode 100644 implementations/web-sdk/e2e/entry-view-tracking.spec.ts create mode 100644 implementations/web-sdk/e2e/flag-view-tracking.spec.ts create mode 100644 implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts diff --git a/.lintstagedrc.yaml b/.lintstagedrc.yaml index db4289fc..ab532150 100644 --- a/.lintstagedrc.yaml +++ b/.lintstagedrc.yaml @@ -1,5 +1,5 @@ '*.{js,jsx,cjs,mjs,ts,tsx}': - - eslint --fix + - eslint --fix --no-warn-ignored - prettier --check --write '*.{json,yaml,yml,md,mdx,mdc,html,css,less}': - prettier --check --write diff --git a/.specify/research/repository-research.md b/.specify/research/repository-research.md index ad9f2499..a8927305 100644 --- a/.specify/research/repository-research.md +++ b/.specify/research/repository-research.md @@ -129,11 +129,13 @@ graph LR - lifecycle interceptor managers (`event`, `state`) - `CoreBase` exposes high-level methods: - personalization path: `identify`, `page`, `screen`, `track` + - personalization resolution path: `getFlag`, `personalizeEntry`, `getMergeTagValue` - mixed path: `trackView` (sticky routes through personalization; non-sticky routes through analytics) - analytics path: `trackClick`, `trackHover`, `trackFlagView` - Resolver utilities are centralized and surfaced through core methods: - - flags resolution + - key-scoped flag resolution (`getFlag`) + - advanced full-map flag resolution through `flagsResolver.resolve(...)` - personalized entry resolution - merge-tag value resolution @@ -142,11 +144,14 @@ graph LR - `CoreStateless`: - no state signal model - composes `AnalyticsStateless` + `PersonalizationStateless` + - `getFlag(...)` does not auto-emit `trackFlagView` - intended for server/SSR usage - `CoreStateful`: - module-global signal model (`@preact/signals-core`) for `consent`, `profile`, `changes`, `selectedPersonalizations`, `event`, `blockedEvent`, `online`, `previewPanelAttached`, `previewPanelOpen` + - key-scoped reactive flag access via `states.flag(name)` + - `getFlag(...)` and `states.flag(name)` auto-emit flag-view analytics - singleton lock enforced via `StatefulRuntimeSingleton` keyed in `globalThis` - explicit lifecycle controls: `destroy()`, `flush()`, `reset()` - preview bridge method `registerPreviewPanel(...)` exposes mutable `signals` and `signalFns` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeefb4c4..8fa46e58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -174,6 +174,13 @@ automatically with each new version. - `pnpm docs:watch` watches for file updates and rebuilds documentation output; useful while writing and updating documentation +When changing public SDK behavior in this pre-release alpha period, update the same pull request to +keep these artifacts aligned: + +- TSDoc/JSDoc comments near changed API surfaces +- package READMEs that document those surfaces +- SpecKit artifacts under `specs/**` and `.specify/research/**` + ## Troubleshooting CI Issues ### E2E Coverage and Environment diff --git a/implementations/web-sdk/e2e/events.spec.ts b/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts similarity index 95% rename from implementations/web-sdk/e2e/events.spec.ts rename to implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts index a7a1a104..a372e4ff 100644 --- a/implementations/web-sdk/e2e/events.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/entry-view-tracking.spec.ts @@ -16,7 +16,7 @@ const variantEntryTexts: Record = { 'This is a baseline content entry for all identified or unidentified users.', } -test.describe('events', () => { +test.describe('entry view tracking', () => { test.describe('without consent', () => { test.beforeEach(async ({ page }) => { await page.clock.install({ time: new Date() }) @@ -78,7 +78,9 @@ test.describe('events', () => { ).toBeVisible() } - const allComponentEvents = page.locator('#event-stream li button[data-view-id]') + const allComponentEvents = page.locator( + '#event-stream li button[data-view-id]:not([data-component-id="boolean"])', + ) await expect.poll(async () => await allComponentEvents.count()).toBeGreaterThanOrEqual(10) 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 new file mode 100644 index 00000000..9d4a2047 --- /dev/null +++ b/implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,33 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +function getFlagAccessComponentEvents(page: Page): Locator { + return page + .locator('#event-stream li button[data-component-id="boolean"]') + .filter({ hasText: /^component$/ }) +} + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await page.getByRole('button', { name: 'Accept Consent' }).click() + await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible() + }) + + test('flag access emits a component event', async ({ page }) => { + await page.goto('/user/flag-access-e2e') + await page.waitForLoadState('domcontentloaded') + + const flagAccessEvents = getFlagAccessComponentEvents(page) + await expect + .poll(async () => await flagAccessEvents.count(), { + message: 'flag access should append a component event in the event stream', + }) + .toBeGreaterThan(0) + + const latestFlagAccessEvent = flagAccessEvents.last() + + await expect(latestFlagAccessEvent).toHaveText('component') + expect(await latestFlagAccessEvent.getAttribute('data-view-id')).toBeNull() + }) +}) diff --git a/implementations/node-sdk+web-sdk/src/index.ejs b/implementations/node-sdk+web-sdk/src/index.ejs index 043924b8..7fd06e80 100644 --- a/implementations/node-sdk+web-sdk/src/index.ejs +++ b/implementations/node-sdk+web-sdk/src/index.ejs @@ -517,6 +517,14 @@ void scheduleRenderEntries() }) + + contentfulOptimization.states.profile.subscribe((profile) => { + if (!profile) return + + // Emit and log flag access + const booleanFlag = contentfulOptimization.getFlag('boolean') + console.log('Flag "boolean" value:', booleanFlag) + }) diff --git a/implementations/node-sdk/src/app.ts b/implementations/node-sdk/src/app.ts index a67a4e12..e0ca1787 100644 --- a/implementations/node-sdk/src/app.ts +++ b/implementations/node-sdk/src/app.ts @@ -168,7 +168,7 @@ app.get('/', limiter, async (req, res) => { }) } - const { profile, selectedPersonalizations, changes } = optimizationResponse ?? {} + const { profile, selectedPersonalizations } = optimizationResponse ?? {} const personalizedEntries = new Map< string, @@ -205,13 +205,10 @@ app.get('/', limiter, async (req, res) => { personalizedEntries.set(entryId, personalizedEntry) }) - const flags = sdk.getCustomFlags(changes) - const pageData = { profile, selectedPersonalizations, entries: personalizedEntries, - flags, } res.render('index', { ...pageData }) diff --git a/implementations/react-native-sdk/App.tsx b/implementations/react-native-sdk/App.tsx index 7b5a9471..7f8245f4 100644 --- a/implementations/react-native-sdk/App.tsx +++ b/implementations/react-native-sdk/App.tsx @@ -32,6 +32,8 @@ function AppContent(): React.JSX.Element { const sdk = useOptimization() const [entries, setEntries] = useState([]) const [sdkError, setSdkError] = useState(null) + const [hasConsent, setHasConsent] = useState(false) + const [hasProfile, setHasProfile] = useState(false) const [isIdentified, setIsIdentified] = useState(false) const [showNavigationTest, setShowNavigationTest] = useState(false) const [showLiveUpdatesTest, setShowLiveUpdatesTest] = useState(false) @@ -41,6 +43,8 @@ function AppContent(): React.JSX.Element { void sdk.page({ properties: { url: 'app' } }) const subscription = sdk.states.profile.subscribe((profile) => { + setHasProfile(profile !== undefined) + if (!profile) { return } @@ -48,11 +52,28 @@ function AppContent(): React.JSX.Element { void fetchEntries(ENTRY_IDS, setEntries, setSdkError) }) + const consentSubscription = sdk.states.consent.subscribe((consent) => { + setHasConsent(consent === true) + }) + return () => { subscription.unsubscribe() + consentSubscription.unsubscribe() } }, [sdk]) + useEffect(() => { + if (!hasConsent || !hasProfile) { + return + } + + const flagSubscription = sdk.states.flag('boolean').subscribe(() => undefined) + + return () => { + flagSubscription.unsubscribe() + } + }, [hasConsent, hasProfile, sdk]) + const handleIdentify = (): void => { void sdk.identify({ userId: 'charles', traits: { identified: true } }) setIsIdentified(true) diff --git a/implementations/react-native-sdk/e2e/flag-view-tracking.test.js b/implementations/react-native-sdk/e2e/flag-view-tracking.test.js new file mode 100644 index 00000000..bc95cd4b --- /dev/null +++ b/implementations/react-native-sdk/e2e/flag-view-tracking.test.js @@ -0,0 +1,22 @@ +const { + clearProfileState, + ELEMENT_VISIBILITY_TIMEOUT, + waitForComponentEventCount, +} = require('./helpers') + +describe('Flag View Tracking', () => { + beforeAll(async () => { + await device.launchApp() + }) + + beforeEach(async () => { + await clearProfileState({ requireFreshAppInstance: true }) + }) + + it('should emit component events for the subscribed boolean flag', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await waitForComponentEventCount('boolean', 1, ELEMENT_VISIBILITY_TIMEOUT) + }) +}) diff --git a/implementations/web-sdk/e2e/entry-view-tracking.spec.ts b/implementations/web-sdk/e2e/entry-view-tracking.spec.ts new file mode 100644 index 00000000..a372e4ff --- /dev/null +++ b/implementations/web-sdk/e2e/entry-view-tracking.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test' + +const variantEntryTexts: Record = { + '1JAU028vQ7v6nB2swl3NBo': 'This is a level 0 nested baseline entry.', + '5i4SdJXw9oDEY0vgO7CwF4': 'This is a level 1 nested baseline entry.', + uaNY4YJ0HFPAX3gKXiRdX: 'This is a level 2 nested baseline entry.', + '1MwiFl4z7gkwqGYdvCmr8c': + 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + '4k6ZyFQnR2POY5IJLLlJRb': 'This is a variant content entry for visitors from Europe.', + '6iyPl6vfDH5AoClf3MtYlh': 'This is a variant content entry for visitors using a desktop browser.', + '1UFf7qr4mHET3HYuYmcpEj': 'This is a variant content entry for new visitors.', + '4bmHsNUaEibELHwWCon3dt': 'This is a variant content entry for an A/B/C experiment: B', + '6zqoWXyiSrf0ja7I2WGtYj': + 'This is a baseline content entry for all visitors with or without a custom event.', + '7pa5bOx8Z9NmNcr7mISvD': + 'This is a baseline content entry for all identified or unidentified users.', +} + +test.describe('entry view tracking', () => { + test.describe('without consent', () => { + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date() }) + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + }) + + test('page event has been emitted', async ({ page }) => { + await expect( + page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }), + ).toBeVisible() + }) + + test('component view events have not been emitted', async ({ page }) => { + for (const entryText of Object.values(variantEntryTexts)) { + const element = page.getByText(entryText) + + await element.scrollIntoViewIfNeeded() + + await page.clock.fastForward('02:00') + } + + await expect(page.locator('#event-stream li button[data-view-id]')).toHaveCount(0) + }) + }) + + test.describe('with consent', () => { + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date() }) + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + + const consent = page.getByRole('button', { name: 'Accept Consent' }) + await consent.click() + }) + + test('page event has been emitted', async ({ page }) => { + await expect( + page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }), + ).toBeVisible() + }) + + test('component view events have been emitted', async ({ page }) => { + for (const entryId of Object.keys(variantEntryTexts)) { + const entryText = variantEntryTexts[entryId] + + if (!entryText) continue + + const element = page.getByText(entryText) + + await element.scrollIntoViewIfNeeded() + + await page.clock.fastForward('02:00') + + await expect( + page + .locator(`#event-stream li button[data-component-id="${entryId}"][data-view-id]`) + .first(), + ).toBeVisible() + } + + const allComponentEvents = page.locator( + '#event-stream li button[data-view-id]:not([data-component-id="boolean"])', + ) + + await expect.poll(async () => await allComponentEvents.count()).toBeGreaterThanOrEqual(10) + + const viewIdButtons = allComponentEvents + const viewIds: string[] = [] + const viewIdCount = await viewIdButtons.count() + + for (let index = 0; index < viewIdCount; index += 1) { + const viewId = await viewIdButtons.nth(index).getAttribute('data-view-id') + + expect(viewId).not.toBeNull() + if (viewId) viewIds.push(viewId) + } + + expect(new Set(viewIds).size).toEqual(viewIds.length) + }) + }) +}) diff --git a/implementations/web-sdk/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk/e2e/flag-view-tracking.spec.ts new file mode 100644 index 00000000..37319990 --- /dev/null +++ b/implementations/web-sdk/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,34 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +function getFlagAccessComponentEvents(page: Page): Locator { + return page + .locator('#event-stream li button[data-component-id="boolean"]') + .filter({ hasText: /^component$/ }) +} + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await page.getByRole('button', { name: 'Accept Consent' }).click() + }) + + test('flag access emits a component event', async ({ page }) => { + const flagAccessEvents = getFlagAccessComponentEvents(page) + const baselineFlagEventCount = await flagAccessEvents.count() + + await page.getByRole('button', { name: 'Identify' }).click() + await expect(page.getByRole('button', { name: 'Reset Profile' })).toBeVisible() + + await expect + .poll(async () => await flagAccessEvents.count(), { + message: 'flag access should append a component event in the event stream', + }) + .toBeGreaterThan(baselineFlagEventCount) + + const latestFlagAccessEvent = flagAccessEvents.last() + + await expect(latestFlagAccessEvent).toHaveText('component') + expect(await latestFlagAccessEvent.getAttribute('data-view-id')).toBeNull() + }) +}) diff --git a/implementations/web-sdk/public/index.html b/implementations/web-sdk/public/index.html index 951855ee..a2210662 100644 --- a/implementations/web-sdk/public/index.html +++ b/implementations/web-sdk/public/index.html @@ -576,10 +576,13 @@

Event Stream

}, ) - // Subscribe to profile state, find entries in the markup, and render them contentfulOptimization.states.profile.subscribe((profile) => { if (!profile) return + // Emit and log flag access + const booleanFlag = contentfulOptimization.getFlag('boolean') + console.log('Flag "boolean" value:', booleanFlag) + toggleIdentity(profile.traits && Object.keys(profile.traits).length) }) diff --git a/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts new file mode 100644 index 00000000..d2a35ba5 --- /dev/null +++ b/implementations/web-sdk_react/e2e/flag-view-tracking.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' + +test.describe('flag view tracking', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + }) + + test('does not emit flag component events without consent', async ({ page }) => { + const flagEvents = page.locator('[data-testid="event-component-boolean"]') + + await expect(flagEvents).toHaveCount(0) + + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + + await expect(flagEvents).toHaveCount(0) + }) + + test('emits flag component events after consent and 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 component 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 component events', + }) + .toBeGreaterThan(afterConsentFlagEventCount) + }) +}) diff --git a/implementations/web-sdk_react/src/App.tsx b/implementations/web-sdk_react/src/App.tsx index fa60c644..5ba10e49 100644 --- a/implementations/web-sdk_react/src/App.tsx +++ b/implementations/web-sdk_react/src/App.tsx @@ -43,22 +43,34 @@ export default function App({ onToggleGlobalLiveUpdates, }: AppProps): JSX.Element { const location = useLocation() - const { sdk, isReady, error } = useOptimization() + const { sdk, error } = useOptimization() const { consent, profile, selectedPersonalizations } = useOptimizationState(sdk?.states) const [entries, setEntries] = useState([]) const [entriesError, setEntriesError] = useState(null) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return } void sdk.page({ properties: { url: location.pathname } }) - }, [isReady, location.pathname, sdk]) + }, [location.pathname, sdk]) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined || consent !== true || profile === undefined) { + return + } + + const subscription = sdk.states.flag('boolean').subscribe(() => undefined) + + return () => { + subscription.unsubscribe() + } + }, [consent, profile?.id, sdk]) + + useEffect(() => { + if (sdk === undefined) { return } @@ -82,7 +94,7 @@ export default function App({ fetchError instanceof Error ? fetchError.message : 'Unknown entry load error' setEntriesError(message) }) - }, [isReady, sdk]) + }, [sdk]) const isIdentified = useMemo(() => isIdentifiedProfile(profile), [profile]) const entriesById = useMemo(() => toEntryMap(entries), [entries]) @@ -94,35 +106,11 @@ export default function App({ const hasPageTwoEntries = entriesById.has(PAGE_TWO_AUTO_ENTRY_ID) && entriesById.has(PAGE_TWO_MANUAL_ENTRY_ID) - const handleIdentify = (): void => { - if (!isReady || sdk === undefined) { - return - } - - void sdk.identify({ userId: 'charles', traits: { identified: true } }) - } - - const handleReset = (): void => { - if (!isReady || sdk === undefined) { - return - } - - sdk.reset() - } - - const handleConsent = (accepted: boolean): void => { - if (!isReady || sdk === undefined) { - return - } - - sdk.consent(accepted) - } - if (error) { return

{error.message}

} - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return

Loading SDK...

} @@ -142,6 +130,18 @@ export default function App({ return

Page Two demo entries are missing from fetched entries.

} + const handleIdentify = (): void => { + void sdk.identify({ userId: 'charles', traits: { identified: true } }) + } + + const handleReset = (): void => { + sdk.reset() + } + + const handleConsent = (accepted: boolean): void => { + sdk.consent(accepted) + } + return (