From 958c147f8f4afd66acd16d5c7206bb23477533d5 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Tue, 16 Jun 2026 17:00:20 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9E=20fix(tracking):=20Support=20view?= =?UTF-8?q?=20tracking=20for=20OptimizedEntry=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes automatic view tracking for React SDK `OptimizedEntry` wrappers that render with `display: contents`, and cleans up the React Web SDK reference implementation so it uses `OptimizedEntry` as the single source of entry metadata. - Added Web SDK support for tracking `display: contents` entry wrappers: - normal elements still use `IntersectionObserver` directly; - single rendered child wrappers observe that child while reporting the logical wrapper; - multi-child/text entries use Range-based aggregate visibility. - Refactored React reference entry rendering to remove manual entry metadata assembly. - Added React-style `OptimizedEntry` props for configurable Web SDK tracking attributes. - Kept derived entry metadata owned by `OptimizedEntry`. - Tightened E2E assertions so manual view tracking is not mistaken for automatic tracking. - Updated Web SDK bundle size budgets to match the measured output. - `pnpm lint` - `pnpm --filter @contentful/optimization-web typecheck` - `pnpm --filter @contentful/optimization-web test:unit` - `pnpm --filter @contentful/optimization-web build` - `pnpm --filter @contentful/optimization-web size:check` - `pnpm build:pkgs` - `pnpm --filter @contentful/optimization-react-web typecheck` - `pnpm --filter @contentful/optimization-react-web test:unit` - `pnpm --filter @contentful/optimization-react-web build` - `pnpm --filter @contentful/optimization-react-web size:check` - `pnpm implementation:run -- react-web-sdk implementation:install` - `pnpm implementation:run -- react-web-sdk typecheck` - `pnpm implementation:run -- react-web-sdk build` - `pnpm implementation:run -- react-web-sdk implementation:test:e2e:run` React reference E2E: `84 passed`. [[NT-3484](https://contentful.atlassian.net/browse/NT-3484)] --- implementations/react-web-sdk/README.md | 82 ++---- .../e2e/entry-click-tracking.spec.ts | 7 +- .../e2e/entry-hover-tracking.spec.ts | 7 +- .../e2e/events-consent-gating.spec.ts | 108 +++++++- .../react-web-sdk/src/pages/HomePage.tsx | 4 +- .../react-web-sdk/src/pages/PageTwoPage.tsx | 4 +- .../src/sections/ContentEntry.tsx | 243 ++++++----------- .../src/sections/NestedContentItem.tsx | 4 +- package.json | 2 +- .../web/frameworks/react-web-sdk/README.md | 17 ++ .../web/frameworks/react-web-sdk/package.json | 4 +- .../optimized-entry/OptimizedEntry.test.tsx | 81 +++++- .../src/optimized-entry/OptimizedEntry.tsx | 19 ++ .../optimized-entry/optimizedEntryUtils.ts | 20 +- packages/web/web-sdk/package.json | 4 +- .../events/view/ElementViewObserver.ts | 133 +++------- .../view/createEntryViewDetector.test.ts | 251 ++++++++++++++++++ .../view/displayContentsViewLifecycle.ts | 85 ++++++ .../events/view/displayContentsViewSource.ts | 230 ++++++++++++++++ .../element-view-observer-support.test.ts | 2 + .../view/element-view-observer-support.ts | 79 ++++++ .../view/elementViewSourceController.ts | 249 +++++++++++++++++ 22 files changed, 1300 insertions(+), 335 deletions(-) create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewLifecycle.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewSource.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/elementViewSourceController.ts diff --git a/implementations/react-web-sdk/README.md b/implementations/react-web-sdk/README.md index ccc4e7428..f95c6c33c 100644 --- a/implementations/react-web-sdk/README.md +++ b/implementations/react-web-sdk/README.md @@ -38,7 +38,7 @@ React framework package. | ---------------------------- | ------------------------------------------------------------------------------------------ | | Provider + initialization | `OptimizationRoot` | | SPA page tracking | `ReactRouterAutoPageTracker` from `@contentful/optimization-react-web/router/react-router` | -| Entry resolution + rendering | `OptimizedEntry` (render-prop), `useEntryResolver().resolveEntry()` | +| Entry resolution + rendering | `OptimizedEntry` render prop | | Live updates (global) | `OptimizationRoot liveUpdates` prop | | Live updates (per-component) | `OptimizedEntry liveUpdates` prop | | Live updates (locked) | `` | @@ -46,8 +46,8 @@ React framework package. | Nested personalization | Nested `` composition | | Consent gating | `sdk.consent()` via `useOptimizationContext()` | | Identify / reset | `sdk.identify()` / `sdk.reset()` via `useOptimizationContext()` | -| Auto view/click/hover | `trackEntryInteraction` on `OptimizationRoot` + `data-ctfl-*` attributes | -| Manual view tracking | `useOptimization().tracking.enableElement()` | +| Auto view/click/hover | `trackEntryInteraction` on `OptimizationRoot` + `OptimizedEntry` tracking props | +| Manual view tracking | `` + `sdk.tracking.enableElement()` | | Flag view tracking | `sdk.states.flag('boolean').subscribe()` | | Analytics event stream | `sdk.states.eventStream.subscribe()` | | Preview panel attachment | Env-gated `attachOptimizationPreviewPanel()` call | @@ -57,8 +57,8 @@ React framework package. This app defines one `APP_LOCALE`, passes it through the provider `locale` prop, and passes it directly to Contentful CDA entry fetches. Do not use `contentful.js` `withAllLocales` or raw CDA -`locale=*` for entries passed to `OptimizedEntry` or `useEntryResolver()`; SDK entry resolution -expects direct single-locale fields such as `fields.nt_experiences` and `fields.nt_variants`. See +`locale=*` for entries passed to `OptimizedEntry`; SDK entry resolution expects direct single-locale +fields such as `fields.nt_experiences` and `fields.nt_variants`. See [Locale handling in the Optimization SDK Suite](../../documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md) for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) @@ -291,66 +291,42 @@ function Controls() { } ``` -### Manual interaction tracking +### Auto tracking props -```tsx -import { useEntryResolver, useOptimization } from '@contentful/optimization-react-web' -import { useEffect, useRef } from 'react' - -function ManuallyTrackedEntry({ entry }) { - const sdk = useOptimization() - const { resolveEntry } = useEntryResolver() - const ref = useRef(null) - const resolved = resolveEntry(entry) - - useEffect(() => { - const el = ref.current - if (!el) return - sdk.tracking.enableElement('views', el, { data: { entryId: resolved.sys.id } }) - return () => sdk.tracking.clearElement('views', el) - }, [resolved.sys.id, sdk.tracking]) - - return
{String(resolved.fields.text)}
-} -``` - -### Auto tracking attributes - -For entries tracked via `trackEntryInteraction`, apply `data-ctfl-*` attributes directly on the -visible content element inside the render prop: +For entries tracked via `trackEntryInteraction`, configure Web SDK tracking behavior through +`OptimizedEntry` props. `OptimizedEntry` derives entry metadata attributes from the resolved entry +state. ```tsx - - {(resolvedEntry) => ( -
- {String(resolvedEntry.fields.text)} -
- )} + + {(resolvedEntry) =>
{String(resolvedEntry.fields.text)}
}
``` > [!NOTE] > > The `OptimizationRoot` `trackEntryInteraction` prop activates automatic view, click, and hover -> tracking for any DOM element that has `data-ctfl-entry-id`. The SDK's MutationObserver registers -> elements as they appear in the DOM after consent is given. +> tracking for resolved `OptimizedEntry` elements. The SDK's MutationObserver registers elements as +> they appear in the DOM after consent is given. + +### Manual view tracking + +For manually observed entries, this implementation still uses `OptimizedEntry` for entry resolution +and disables automatic view tracking with `trackViews={false}`. The render prop receives the +resolved entry, and the rendered element is registered with +`sdk.tracking.enableElement('views', element, { data: { entryId: resolvedEntry.sys.id } })`. ## Code orientation -| File or area | Purpose | -| ------------------------------------------ | ----------------------------------------------------------------- | -| `src/main.tsx` | Configures `OptimizationRoot` and `ReactRouterAutoPageTracker` | -| `src/App.tsx` | Subscribes to provider state and renders route-level controls | -| `src/sections/ContentEntry.tsx` | Demonstrates `OptimizedEntry`, `useEntryResolver()`, and tracking | -| `src/sections/LiveUpdatesExampleEntry.tsx` | Demonstrates locked and live entry resolution | -| `src/components/RichTextRenderer.tsx` | Demonstrates merge tag rendering with `useMergeTagResolver()` | -| `src/components/AnalyticsEventDisplay.tsx` | Displays event stream output from `sdk.states.eventStream` | -| Manual `selectedOptimizations` lock logic | `` | +| File or area | Purpose | +| ------------------------------------------ | -------------------------------------------------------------- | +| `src/main.tsx` | Configures `OptimizationRoot` and `ReactRouterAutoPageTracker` | +| `src/App.tsx` | Subscribes to provider state and renders route-level controls | +| `src/sections/ContentEntry.tsx` | Demonstrates `OptimizedEntry` tracking props and manual views | +| `src/sections/LiveUpdatesExampleEntry.tsx` | Demonstrates locked and live entry resolution | +| `src/components/RichTextRenderer.tsx` | Demonstrates merge tag rendering with `useMergeTagResolver()` | +| `src/components/AnalyticsEventDisplay.tsx` | Displays event stream output from `sdk.states.eventStream` | +| Manual `selectedOptimizations` lock logic | `` | **What stays the same:** `contentfulClient.ts`, entry/route config, type definitions, `RichTextRenderer`, E2E test files, page/section component structure. diff --git a/implementations/react-web-sdk/e2e/entry-click-tracking.spec.ts b/implementations/react-web-sdk/e2e/entry-click-tracking.spec.ts index ea7616411..fe126f513 100644 --- a/implementations/react-web-sdk/e2e/entry-click-tracking.spec.ts +++ b/implementations/react-web-sdk/e2e/entry-click-tracking.spec.ts @@ -25,7 +25,12 @@ const clickScenarios: ClickScenario[] = [ ] async function readResolvedEntryId(page: Page, entryTestId: string): Promise { - const entryId = await page.getByTestId(entryTestId).getAttribute('data-ctfl-entry-id') + const entryMetadata = page + .getByTestId(entryTestId) + .locator('xpath=ancestor::*[@data-ctfl-entry-id][1]') + const entryId = await entryMetadata + .getAttribute('data-ctfl-entry-id', { timeout: 500 }) + .catch(() => undefined) return entryId ?? '' } diff --git a/implementations/react-web-sdk/e2e/entry-hover-tracking.spec.ts b/implementations/react-web-sdk/e2e/entry-hover-tracking.spec.ts index 83500179c..4af46f96b 100644 --- a/implementations/react-web-sdk/e2e/entry-hover-tracking.spec.ts +++ b/implementations/react-web-sdk/e2e/entry-hover-tracking.spec.ts @@ -37,9 +37,12 @@ async function movePointerAwayFromEntries(page: Page): Promise { } async function readResolvedEntryId(page: Page): Promise { - const entryId = await page + const entryMetadata = page .getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) - .getAttribute('data-ctfl-entry-id') + .locator('xpath=ancestor::*[@data-ctfl-entry-id][1]') + const entryId = await entryMetadata + .getAttribute('data-ctfl-entry-id', { timeout: 500 }) + .catch(() => undefined) return entryId ?? '' } diff --git a/implementations/react-web-sdk/e2e/events-consent-gating.spec.ts b/implementations/react-web-sdk/e2e/events-consent-gating.spec.ts index 36b8a420c..cadba22ac 100644 --- a/implementations/react-web-sdk/e2e/events-consent-gating.spec.ts +++ b/implementations/react-web-sdk/e2e/events-consent-gating.spec.ts @@ -1,12 +1,76 @@ -import { type Page, expect, test } from '@playwright/test' +import { type Locator, type Page, expect, test } from '@playwright/test' -async function scrollThroughEntries(page: Page): Promise { - const entries = page.locator('[data-testid^="content-"]') +type EntryObservationScope = 'auto' | 'manual' + +const ENTRY_SCOPE_SELECTOR_BY_SCOPE: Record = { + auto: '#auto-observed', + manual: '#manually-observed', +} + +function resolveEntryLocator(page: Page, scope: EntryObservationScope): Locator { + return page.locator( + `${ENTRY_SCOPE_SELECTOR_BY_SCOPE[scope]} [data-testid^="content-"]:not([data-testid^="content-entry-"])`, + ) +} + +function resolveEntryViewEvent(page: Page, entryId: string): Locator { + return page.locator('[data-testid^="event-view-"]').filter({ + hasText: `Entry/Flag: ${entryId}`, + }) +} + +async function waitForResolvedEntryWrappers(page: Page): Promise { + await expect + .poll(async () => await page.locator('[data-ctfl-entry-id]').count(), { + message: 'resolved entry wrappers should be available', + }) + .toBeGreaterThan(0) + await expect(page.locator('[data-ctfl-loading-layout-target="true"]')).toHaveCount(0) +} + +async function readResolvedEntryId(page: Page, entryTestId: string): Promise { + const entryMetadata = page + .getByTestId(entryTestId) + .locator('xpath=ancestor::*[@data-ctfl-entry-id][1]') + const entryId = await entryMetadata + .getAttribute('data-ctfl-entry-id', { timeout: 500 }) + .catch(() => undefined) + + return entryId ?? '' +} + +async function resolveEntryTestIds(page: Page, scope: EntryObservationScope): Promise { + await waitForResolvedEntryWrappers(page) + + const entries = resolveEntryLocator(page, scope) const entryCount = await entries.count() + const entryTestIds: string[] = [] for (let index = 0; index < entryCount; index += 1) { - await entries.nth(index).scrollIntoViewIfNeeded() + const entryTestId = await entries.nth(index).getAttribute('data-testid') + + if (entryTestId) { + entryTestIds.push(entryTestId) + } + } + + return entryTestIds +} + +async function scrollThroughEntries(page: Page, scope: EntryObservationScope): Promise { + const entryTestIds = await resolveEntryTestIds(page, scope) + const resolvedEntryIds: string[] = [] + + for (const entryTestId of entryTestIds) { + const resolvedEntryId = await readResolvedEntryId(page, entryTestId) + if (resolvedEntryId) { + resolvedEntryIds.push(resolvedEntryId) + } + + await page.getByTestId(entryTestId).scrollIntoViewIfNeeded() } + + return resolvedEntryIds } test.describe('consent gating', () => { @@ -16,26 +80,50 @@ test.describe('consent gating', () => { await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() }) - test('allows page events without consent but gates entry view events', async ({ page }) => { + test('allows page events without consent but gates automatic and manual entry view events', async ({ + page, + }) => { const pageEvents = page.locator('[data-testid^="event-page-"]') const viewEvents = page.locator('[data-testid^="event-view-"]') await expect(pageEvents.first()).toBeVisible() - await scrollThroughEntries(page) + await scrollThroughEntries(page, 'auto') + await scrollThroughEntries(page, 'manual') await expect(viewEvents).toHaveCount(0) }) - test('emits entry view events after consent is accepted', async ({ page }) => { + test('emits automatic entry view events after consent is accepted', async ({ page }) => { + const pageEvents = page.locator('[data-testid^="event-page-"]') + + await expect(pageEvents.first()).toBeVisible() + + await page.getByRole('button', { name: 'Accept Consent' }).click() + await expect(page.getByTestId('consent-status')).toHaveText('Consent: true') + const resolvedEntryIds = await scrollThroughEntries(page, 'auto') + + for (const resolvedEntryId of resolvedEntryIds) { + await expect( + resolveEntryViewEvent(page, resolvedEntryId), + `automatic view event should render for "${resolvedEntryId}"`, + ).toBeVisible() + } + }) + + test('emits manual entry view events after consent is accepted', async ({ page }) => { const pageEvents = page.locator('[data-testid^="event-page-"]') - const viewEvents = page.locator('[data-testid^="event-view-"]') await expect(pageEvents.first()).toBeVisible() await page.getByRole('button', { name: 'Accept Consent' }).click() await expect(page.getByTestId('consent-status')).toHaveText('Consent: true') - await scrollThroughEntries(page) + const resolvedEntryIds = await scrollThroughEntries(page, 'manual') - await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0) + for (const resolvedEntryId of resolvedEntryIds) { + await expect( + resolveEntryViewEvent(page, resolvedEntryId), + `manual view event should render for "${resolvedEntryId}"`, + ).toBeVisible() + } }) }) diff --git a/implementations/react-web-sdk/src/pages/HomePage.tsx b/implementations/react-web-sdk/src/pages/HomePage.tsx index b8aeee141..4c16e3dd5 100644 --- a/implementations/react-web-sdk/src/pages/HomePage.tsx +++ b/implementations/react-web-sdk/src/pages/HomePage.tsx @@ -91,7 +91,7 @@ function AutoObservedEntries({ entriesById }: AutoObservedEntriesProps): JSX.Ele key={entry.sys.id} clickScenario={AUTO_OBSERVED_CLICK_SCENARIO_BY_ENTRY_ID[entry.sys.id]} entry={entry} - observation="auto" + viewTracking="auto" /> ) })} @@ -112,7 +112,7 @@ function ManuallyObservedEntries({ entriesById }: ManuallyObservedEntriesProps): return null } - return + return })} ) diff --git a/implementations/react-web-sdk/src/pages/PageTwoPage.tsx b/implementations/react-web-sdk/src/pages/PageTwoPage.tsx index e7d84e666..5a2b9ef2a 100644 --- a/implementations/react-web-sdk/src/pages/PageTwoPage.tsx +++ b/implementations/react-web-sdk/src/pages/PageTwoPage.tsx @@ -47,7 +47,7 @@ export function PageTwoPage(): JSX.Element { {pageTwoAutoEntry ? (

Auto tracked example

- +
) : (

Auto tracked entry is unavailable.

@@ -56,7 +56,7 @@ export function PageTwoPage(): JSX.Element { {pageTwoManualEntry ? (

Manual tracked example

- +
) : (

Manual tracked entry is unavailable.

diff --git a/implementations/react-web-sdk/src/sections/ContentEntry.tsx b/implementations/react-web-sdk/src/sections/ContentEntry.tsx index ba87c2da1..dcb340206 100644 --- a/implementations/react-web-sdk/src/sections/ContentEntry.tsx +++ b/implementations/react-web-sdk/src/sections/ContentEntry.tsx @@ -1,22 +1,19 @@ -import { useEntryResolver, useOptimization } from '@contentful/optimization-react-web' -import type { JSX, RefObject } from 'react' +import { OptimizedEntry, useOptimization } from '@contentful/optimization-react-web' +import type { JSX } from 'react' import { useEffect, useRef } from 'react' import { RichTextRenderer } from '../components/RichTextRenderer' import type { ContentEntry as ContentEntryType, RichTextDocument } from '../types/contentful' export type EntryClickScenario = 'direct' | 'descendant' | 'ancestor' +type ViewTrackingMode = 'auto' | 'manual' interface ContentEntryProps { clickScenario?: EntryClickScenario entry: ContentEntryType - observation: 'auto' | 'manual' + viewTracking: ViewTrackingMode } -interface ResolvedOptimizationMeta { - experienceId: string | undefined - sticky: boolean | undefined - variantIndex: number | undefined -} +const HOVER_DURATION_UPDATE_INTERVAL_MS = 1000 function isRichTextField(field: unknown): field is RichTextDocument { return ( @@ -33,173 +30,99 @@ function getEntryText(entry: ContentEntryType): string { return typeof entry.fields.text === 'string' ? entry.fields.text : 'No content' } -function resolveOptimizationMeta(selectedOptimization: unknown): ResolvedOptimizationMeta { - if (typeof selectedOptimization !== 'object' || selectedOptimization === null) { - return { experienceId: undefined, sticky: undefined, variantIndex: undefined } - } - - const { experienceId, sticky, variantIndex } = selectedOptimization as { - experienceId?: unknown - sticky?: unknown - variantIndex?: unknown - } - - return { - experienceId: typeof experienceId === 'string' ? experienceId : undefined, - sticky: typeof sticky === 'boolean' ? sticky : undefined, - variantIndex: typeof variantIndex === 'number' ? variantIndex : undefined, - } -} - -interface ContentDivProps { - baselineEntry: ContentEntryType - clickScenario: EntryClickScenario | undefined - containerRef: RefObject - meta: ResolvedOptimizationMeta - observation: 'auto' | 'manual' - resolvedEntry: ContentEntryType -} - -function ContentDiv({ - baselineEntry, +export function ContentEntry({ clickScenario, - containerRef, - meta, - observation, - resolvedEntry, -}: ContentDivProps): JSX.Element { - const richTextField = Object.values(resolvedEntry.fields).find(isRichTextField) - const fullLabel = `Entry: ${resolvedEntry.sys.id}` - const { experienceId, sticky, variantIndex } = meta - - const autoAttrs = - observation === 'auto' - ? { - 'data-ctfl-entry-id': resolvedEntry.sys.id, - 'data-ctfl-baseline-id': baselineEntry.sys.id, - 'data-ctfl-optimization-id': experienceId, - 'data-ctfl-sticky': sticky === undefined ? undefined : String(sticky), - 'data-ctfl-variant-index': variantIndex === undefined ? undefined : String(variantIndex), - 'data-ctfl-hover-duration-update-interval-ms': '1000', - } - : { 'data-entry-id': baselineEntry.sys.id } - - const directClickAttrs = - clickScenario === 'direct' ? ({ 'data-ctfl-clickable': 'true' } as const) : undefined - - return ( -
-
- {richTextField ? ( - - ) : ( -

{getEntryText(resolvedEntry)}

- )} -

{`[Entry: ${baselineEntry.sys.id}]`}

-
- - {clickScenario === 'descendant' ? ( - - ) : null} -
+ entry, + viewTracking, +}: ContentEntryProps): JSX.Element { + const sdk = useOptimization() + const manuallyTrackedElement = useRef(null) + const autoTrackViews = viewTracking === 'auto' + + useEffect( + () => () => { + const { current } = manuallyTrackedElement + if (!current) { + return + } + + sdk.tracking.clearElement('views', current) + }, + [sdk.tracking], ) -} -interface TrackedContentProps { - baselineEntry: ContentEntryType - clickScenario: EntryClickScenario | undefined - containerRef: RefObject - observation: 'auto' | 'manual' -} + const updateManualViewElement = (element: HTMLDivElement | null, entryId: string): void => { + const { current: previousElement } = manuallyTrackedElement -function TrackedContent({ - baselineEntry, - clickScenario, - containerRef, - observation, -}: TrackedContentProps): JSX.Element { - const sdk = useOptimization() - const { resolveEntry, resolveEntryData } = useEntryResolver() - const resolvedEntry = resolveEntry(baselineEntry) as ContentEntryType - const { selectedOptimization } = resolveEntryData(baselineEntry) - const meta = resolveOptimizationMeta(selectedOptimization) - - useEffect(() => { - if (observation !== 'manual') { - return undefined + if (previousElement && previousElement !== element) { + sdk.tracking.clearElement('views', previousElement) } - const { current: element } = containerRef - if (!element) { - return undefined + manuallyTrackedElement.current = element + + if (!element || viewTracking !== 'manual') { + return } sdk.tracking.enableElement('views', element, { - data: { - entryId: resolvedEntry.sys.id, - optimizationId: meta.experienceId, - sticky: meta.sticky, - variantIndex: meta.variantIndex, - }, + data: { entryId }, }) - - return () => { - sdk.tracking.clearElement('views', element) - } - }, [ - containerRef, - meta.experienceId, - meta.sticky, - meta.variantIndex, - observation, - resolvedEntry.sys.id, - sdk.tracking, - ]) - - const content = ( - - ) - - if (clickScenario === 'ancestor' && observation === 'auto') { - return ( -
- {content} -
- ) } - return content -} - -export function ContentEntry({ - clickScenario, - entry, - observation, -}: ContentEntryProps): JSX.Element { - const containerRef = useRef(null) - return (
- + clickable={autoTrackViews && clickScenario === 'direct'} + hoverDurationUpdateIntervalMs={ + autoTrackViews ? HOVER_DURATION_UPDATE_INTERVAL_MS : undefined + } + trackViews={autoTrackViews ? undefined : false} + > + {(resolvedEntry) => { + const asCf = resolvedEntry as ContentEntryType + const richTextField = Object.values(asCf.fields).find(isRichTextField) + const fullLabel = `Entry: ${asCf.sys.id}` + + const content = ( +
{ + updateManualViewElement(element, asCf.sys.id) + } + : undefined + } + data-testid={`content-${entry.sys.id}`} + > +
+ {richTextField ? ( + + ) : ( +

{getEntryText(asCf)}

+ )} +

{`[Entry: ${entry.sys.id}]`}

+
+ + {clickScenario === 'descendant' ? ( + + ) : null} +
+ ) + + if (autoTrackViews && clickScenario === 'ancestor') { + return ( +
+ {content} +
+ ) + } + + return content + }} +
) } diff --git a/implementations/react-web-sdk/src/sections/NestedContentItem.tsx b/implementations/react-web-sdk/src/sections/NestedContentItem.tsx index 9ccef98a6..37f81e133 100644 --- a/implementations/react-web-sdk/src/sections/NestedContentItem.tsx +++ b/implementations/react-web-sdk/src/sections/NestedContentItem.tsx @@ -22,7 +22,7 @@ function renderText(entry: ContentEntry): string { export function NestedContentItem({ entry }: NestedContentItemProps): JSX.Element { return ( - + {(resolvedEntry) => { const asCf = resolvedEntry as ContentEntry const nestedEntries = Array.isArray(asCf.fields.nested) ? asCf.fields.nested : [] @@ -30,7 +30,7 @@ export function NestedContentItem({ entry }: NestedContentItemProps): JSX.Elemen const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]` return ( -
+

{text}

{`[Entry: ${resolvedEntry.sys.id}]`}

diff --git a/package.json b/package.json index 01477fe5f..f5b075a25 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "engines": { "node": ">=20.19.0" }, - "packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916", + "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620", "devDependencies": { "@rstest/core": "catalog:", "@eslint/js": "^10.0.1", diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 77e00f682..4e6ff7c70 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -285,6 +285,23 @@ automatic tracking in the root config when views, clicks, or hovers must be dete ``` +Use `OptimizedEntry` props to configure Web SDK entry-tracking attributes without setting +`data-ctfl-*` metadata manually: + +```tsx + + {(resolvedEntry) => } + +``` + +`OptimizedEntry` derives entry ID, baseline ID, optimization ID, sticky state, variant index, and +duplication scope from the resolved entry state. + Use `sdk.tracking.enableElement(...)` from `useOptimization()` for manual element overrides. ### Router page events diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index da7bd8b9a..bca0e2d0d 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -121,8 +121,8 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 3300, - "index.mjs": 2400 + "index.cjs": 3400, + "index.mjs": 2550 } } }, diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index c3742ece8..be0242c57 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx @@ -1,6 +1,6 @@ import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import { act } from 'react' -import { OptimizedEntry } from './OptimizedEntry' +import { act, createElement } from 'react' +import { OptimizedEntry, type OptimizedEntryProps } from './OptimizedEntry' import { createRuntime, getRequiredElement, @@ -11,6 +11,7 @@ import { renderComponent, renderComponentToString, renderToStringWithoutWindow, + type TestEntry, } from './OptimizedEntry.testUtils' describe('OptimizedEntry', () => { @@ -124,7 +125,12 @@ describe('OptimizedEntry', () => { }) const view = await renderComponent( - 'loading'}> + 'loading'} + > {(resolved) => readTitle(resolved)} , optimization, @@ -133,13 +139,17 @@ describe('OptimizedEntry', () => { expect(view.container.textContent).toContain('loading') const loadingWrapper = getWrapper(view.container) + expect(loadingWrapper.dataset.ctflClickable).toBeUndefined() expect(loadingWrapper.dataset.ctflEntryId).toBeUndefined() + expect(loadingWrapper.dataset.ctflHoverDurationUpdateIntervalMs).toBeUndefined() await emit(variantOneState) expect(view.container.textContent).toContain('variant-a') const resolvedWrapper = getWrapper(view.container) + expect(resolvedWrapper.dataset.ctflClickable).toBe('true') expect(resolvedWrapper.dataset.ctflEntryId).toBe('variant-a') + expect(resolvedWrapper.dataset.ctflHoverDurationUpdateIntervalMs).toBe('1000') await view.unmount() }) @@ -224,6 +234,7 @@ describe('OptimizedEntry', () => { await emit(variantTwoState) const wrapper = getWrapper(view.container) + expect(wrapper.dataset.ctflBaselineId).toBe('baseline') expect(wrapper.dataset.ctflEntryId).toBe('variant-b') expect(wrapper.dataset.ctflOptimizationId).toBe('exp-hero') expect(wrapper.dataset.ctflSticky).toBe('false') @@ -233,20 +244,80 @@ describe('OptimizedEntry', () => { await view.unmount() }) - it('maps per-entry interaction tracking overrides to data attributes', async () => { + it('maps configurable Web SDK attributes to data attributes', async () => { const { optimization } = createRuntime((entry) => ({ entry })) const view = await renderComponent( - + {(resolved) => readTitle(resolved)} , optimization, ) const wrapper = getWrapper(view.container) + expect(wrapper.dataset.ctflClickable).toBe('true') + expect(wrapper.dataset.ctflHoverDurationUpdateIntervalMs).toBe('1000') expect(wrapper.dataset.ctflTrackClicks).toBe('true') expect(wrapper.dataset.ctflTrackHovers).toBe('false') expect(wrapper.dataset.ctflTrackViews).toBe('false') + expect(wrapper.dataset.ctflViewDurationUpdateIntervalMs).toBe('2000') + + await view.unmount() + }) + + it('does not expose caller overrides for derived metadata attributes', async () => { + type DerivedMetadataOverrideProps = OptimizedEntryProps & { + 'data-ctfl-baseline-id': string + 'data-ctfl-duplication-scope': string + 'data-ctfl-entry-id': string + 'data-ctfl-optimization-id': string + 'data-ctfl-sticky': string + 'data-ctfl-variant-index': string + } + + const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { + const selected = selectedOptimizations?.[0] + if (!selected) return { entry } + + return { + entry: variantB, + selectedOptimization: { + ...selected, + duplicationScope: 'session', + }, + } + }) + + const props: DerivedMetadataOverrideProps = { + baselineEntry: baseline, + children: (resolved: TestEntry) => readTitle(resolved), + 'data-ctfl-baseline-id': 'caller-baseline', + 'data-ctfl-duplication-scope': 'caller-scope', + 'data-ctfl-entry-id': 'caller-entry', + 'data-ctfl-optimization-id': 'caller-exp', + 'data-ctfl-sticky': 'true', + 'data-ctfl-variant-index': '99', + } + + const view = await renderComponent(createElement(OptimizedEntry, props), optimization) + + await emit(variantTwoState) + + const wrapper = getWrapper(view.container) + expect(wrapper.dataset.ctflBaselineId).toBe('baseline') + expect(wrapper.dataset.ctflDuplicationScope).toBe('session') + expect(wrapper.dataset.ctflEntryId).toBe('variant-b') + expect(wrapper.dataset.ctflOptimizationId).toBe('exp-hero') + expect(wrapper.dataset.ctflSticky).toBe('false') + expect(wrapper.dataset.ctflVariantIndex).toBe('2') await view.unmount() }) 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 f1e754b58..d5d26c3ee 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 @@ -57,6 +57,10 @@ export interface OptimizedEntryProps { * Optional fallback rendered while optimization state is unresolved. */ loadingFallback?: OptimizedEntryLoadingFallback + /** + * Marks the optimized entry wrapper as a click target for entry click tracking. + */ + clickable?: boolean /** * Per-component override for click tracking. */ @@ -69,6 +73,14 @@ export interface OptimizedEntryProps { * Per-component override for view tracking. */ trackViews?: boolean + /** + * Per-component override for view-duration update events, in milliseconds. + */ + viewDurationUpdateIntervalMs?: number + /** + * Per-component override for hover-duration update events, in milliseconds. + */ + hoverDurationUpdateIntervalMs?: number } const WRAPPER_STYLE = Object.freeze({ display: 'contents' as const }) @@ -114,9 +126,12 @@ export function OptimizedEntry({ testId, 'data-testid': dataTestIdProp, loadingFallback, + clickable, + hoverDurationUpdateIntervalMs, trackClicks, trackHovers, trackViews, + viewDurationUpdateIntervalMs, }: OptimizedEntryProps): JSX.Element | null { const { sys: { id: baselineEntryId }, @@ -186,9 +201,13 @@ export function OptimizedEntry({ } const trackingAttributes = resolveTrackingAttributes(resolvedData, { + baselineEntryId, + clickable, + hoverDurationUpdateIntervalMs, trackClicks, trackHovers, trackViews, + viewDurationUpdateIntervalMs, }) return ( 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 0513d01cd..ceee08443 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 @@ -16,9 +16,13 @@ export interface LoadingRenderState { } export interface TrackingAttributeOptions { + baselineEntryId: string + clickable?: boolean + hoverDurationUpdateIntervalMs?: number trackClicks?: boolean trackHovers?: boolean trackViews?: boolean + viewDurationUpdateIntervalMs?: number } export type LoadingLayoutTargetStyle = Pick @@ -82,7 +86,7 @@ export function resolveShouldLiveUpdate(params: { export function resolveTrackingAttributes( resolvedData: ResolvedData, - options: TrackingAttributeOptions = {}, + options: TrackingAttributeOptions, ): Record { const { selectedOptimization, @@ -90,17 +94,29 @@ export function resolveTrackingAttributes( sys: { id: entryId }, }, } = resolvedData - const { trackClicks, trackHovers, trackViews } = options + const { + baselineEntryId, + clickable, + hoverDurationUpdateIntervalMs, + trackClicks, + trackHovers, + trackViews, + viewDurationUpdateIntervalMs, + } = options return { + 'data-ctfl-baseline-id': baselineEntryId, + 'data-ctfl-clickable': clickable === true ? true : undefined, 'data-ctfl-duplication-scope': resolveDuplicationScope(selectedOptimization), 'data-ctfl-entry-id': entryId, + 'data-ctfl-hover-duration-update-interval-ms': hoverDurationUpdateIntervalMs, 'data-ctfl-optimization-id': selectedOptimization?.experienceId, 'data-ctfl-sticky': selectedOptimization?.sticky, 'data-ctfl-track-clicks': trackClicks, 'data-ctfl-track-hovers': trackHovers, 'data-ctfl-track-views': trackViews, 'data-ctfl-variant-index': selectedOptimization?.variantIndex ?? 0, + 'data-ctfl-view-duration-update-interval-ms': viewDurationUpdateIntervalMs, } } diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 6d90909ad..213ffda3d 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -91,8 +91,8 @@ "bundleSize": { "gzipBudgets": { "contentful-optimization-web.umd.js": 33000, - "index.cjs": 10000, - "index.mjs": 9000 + "index.cjs": 11500, + "index.mjs": 10600 } } }, diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts b/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts index ddecbda64..30161f3e7 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts @@ -28,12 +28,16 @@ import { type ElementViewObserverOptions, type Interval, NOW, - Num, - type PerElementEffectiveOptions, clearFireTimer, + createElementState, derefElement, + getRemainingMsUntilNextFire, + initElementViewObserverOptions, isPageVisible, + pauseVisibilityCycle, + resetVisibilityCycle, } from './element-view-observer-support' +import ElementViewSourceController from './elementViewSourceController' const logger = createScopedLogger('Web:ElementViewObserver') const createViewId = (): string => crypto.randomUUID() @@ -48,6 +52,7 @@ class ElementViewObserver { private readonly callback: ElementViewCallback private readonly opts: EffectiveObserverOptions private readonly io: IntersectionObserver + private readonly sourceController: ElementViewSourceController private readonly states = new WeakMap() private readonly activeStates = new Set() private cleanupVisibilityListener?: () => void @@ -55,7 +60,7 @@ class ElementViewObserver { public constructor(callback: ElementViewCallback, options?: ElementViewObserverOptions) { this.callback = callback - this.opts = ElementViewObserver.initOptions(options) + this.opts = initElementViewObserverOptions(options) this.io = new IntersectionObserver( (entries) => { this.onIntersect(entries) @@ -66,6 +71,12 @@ class ElementViewObserver { threshold: this.opts.minVisibleRatio === 0 ? [0] : [0, this.opts.minVisibleRatio], }, ) + this.sourceController = new ElementViewSourceController(this.io, this.opts, { + onDropped: this.finalizeDroppedState.bind(this), + onHidden: this.onVisibilityEnd.bind(this), + onVisible: this.onIntersecting.bind(this), + sweep: this.sweepOrphans.bind(this), + }) this.cleanupVisibilityListener = addVisibilityChangeListener(() => { this.onPageVisibilityChange() @@ -76,21 +87,23 @@ class ElementViewObserver { let state = this.states.get(element) if (!state) { - state = this.createState(element, options) + state = createElementState(element, this.opts, options) this.states.set(element, state) this.activeStates.add(state) this.ensureSweeper() } - this.io.observe(element) + this.sourceController.apply(state, false) } public unobserve(element: Element): void { - this.io.unobserve(element) - const state = this.states.get(element) - if (!state) return + if (!state) { + this.io.unobserve(element) + return + } + this.sourceController.remove(state) clearFireTimer(state) state.done = true this.activeStates.delete(state) @@ -103,11 +116,13 @@ class ElementViewObserver { public disconnect(): void { this.io.disconnect() + this.sourceController.disconnect() for (const state of this.activeStates) { clearFireTimer(state) state.done = true state.strongRef = null + state.target = null } this.activeStates.clear() @@ -118,47 +133,6 @@ class ElementViewObserver { this.stopSweeper() } - private static initOptions(options?: ElementViewObserverOptions): EffectiveObserverOptions { - return { - dwellTimeMs: Num.nonNeg(options?.dwellTimeMs, DEFAULTS.DWELL_MS), - viewDurationUpdateIntervalMs: Num.nonNeg( - options?.viewDurationUpdateIntervalMs, - DEFAULTS.VIEW_DURATION_UPDATE_INTERVAL_MS, - ), - minVisibleRatio: Num.clamp01(options?.minVisibleRatio, DEFAULTS.RATIO), - root: options?.root ?? null, - rootMargin: options?.rootMargin ?? '0px', - } - } - - private createState(element: Element, options?: ElementViewElementOptions): ElementState { - const opts: PerElementEffectiveOptions = { - dwellTimeMs: Num.nonNeg(options?.dwellTimeMs, this.opts.dwellTimeMs), - viewDurationUpdateIntervalMs: Num.nonNeg( - options?.viewDurationUpdateIntervalMs, - this.opts.viewDurationUpdateIntervalMs, - ), - } - - const hasWeakRef = typeof WeakRef === 'function' - - return { - ref: hasWeakRef ? new WeakRef(element) : null, - strongRef: hasWeakRef ? null : element, - opts, - data: options?.data, - accumulatedMs: 0, - visibleSince: null, - fireTimer: null, - attempts: 0, - viewId: null, - done: false, - inFlight: false, - lastKnownVisible: false, - pendingFinal: false, - } - } - private onPageVisibilityChange(): void { const now = NOW() const hidden = !isPageVisible() @@ -166,23 +140,12 @@ class ElementViewObserver { for (const state of this.activeStates) { if (state.done) continue - hidden - ? ElementViewObserver.pauseVisibilityCycle(state, now) - : this.resumeVisibilityCycle(state, now) + hidden ? pauseVisibilityCycle(state, now) : this.resumeVisibilityCycle(state, now) } - this.sweepOrphans() - } - - private static pauseVisibilityCycle(state: ElementState, now: number): void { - if (!state.lastKnownVisible) return - - if (state.visibleSince !== null) { - state.accumulatedMs += now - state.visibleSince - state.visibleSince = null - } + if (!hidden) this.sourceController.requestVirtualMeasurement() - clearFireTimer(state) + this.sweepOrphans() } private resumeVisibilityCycle(state: ElementState, now: number): void { @@ -197,17 +160,21 @@ class ElementViewObserver { const now = NOW() for (const entry of entries) { - const state = this.states.get(entry.target) + const states = this.sourceController.getStatesForTarget(entry.target) - if (!state || state.done) continue + if (!states) continue - const intersectsThreshold = - entry.isIntersecting && entry.intersectionRatio >= this.opts.minVisibleRatio + for (const state of states) { + if (state.done) continue - if (intersectsThreshold) { - this.onIntersecting(state, now) - } else { - this.onVisibilityEnd(state, now) + const intersectsThreshold = + entry.isIntersecting && entry.intersectionRatio >= this.opts.minVisibleRatio + + if (intersectsThreshold) { + this.onIntersecting(state, now) + } else { + this.onVisibilityEnd(state, now) + } } } @@ -250,7 +217,7 @@ class ElementViewObserver { state.lastKnownVisible = false if (state.viewId === null || state.attempts === 0) { - ElementViewObserver.resetVisibilityCycle(state) + resetVisibilityCycle(state) return } @@ -262,16 +229,6 @@ class ElementViewObserver { void this.attemptCallback(state, state.accumulatedMs) } - private static resetVisibilityCycle(state: ElementState): void { - state.lastKnownVisible = false - state.pendingFinal = false - state.accumulatedMs = 0 - state.visibleSince = null - state.attempts = 0 - state.viewId = null - clearFireTimer(state) - } - private scheduleFireIfDue(state: ElementState, now: number): void { if ( state.done || @@ -286,7 +243,7 @@ class ElementViewObserver { const elapsed = state.accumulatedMs + (state.visibleSince !== null ? now - state.visibleSince : 0) - const remaining = ElementViewObserver.getRemainingMsUntilNextFire(state, elapsed) + const remaining = getRemainingMsUntilNextFire(state, elapsed) if (remaining <= 0) { this.trigger(state, now) @@ -365,7 +322,7 @@ class ElementViewObserver { return } - ElementViewObserver.resetVisibilityCycle(state) + resetVisibilityCycle(state) return } @@ -376,14 +333,8 @@ class ElementViewObserver { this.scheduleFireIfDue(state, now) } - private static getRemainingMsUntilNextFire(state: ElementState, elapsedMs: number): number { - const requiredElapsedMs = - state.opts.dwellTimeMs + state.attempts * state.opts.viewDurationUpdateIntervalMs - - return requiredElapsedMs - elapsedMs - } - private finalizeDroppedState(state: ElementState): void { + this.sourceController.remove(state) finalizeDroppedState(state, { activeStates: this.activeStates, states: this.states }) this.maybeStopSweeper() } diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts index e32110b29..afca95168 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts @@ -1,6 +1,41 @@ import { advance, createEntryTrackingHarness, installIOPolyfill } from '../../../test/helpers' import { createEntryViewDetector, type EntryViewTrackingCore } from './createEntryViewDetector' +const rect = (left: number, top: number, width: number, height: number): DOMRect => + new DOMRect(left, top, width, height) + +const setRect = (element: Element, value: DOMRectReadOnly): void => { + rs.spyOn(element, 'getBoundingClientRect').mockReturnValue(value) +} + +const toDomRectList = (rects: DOMRect[]): DOMRectList => { + const list: DOMRectList = { + [Symbol.iterator]: () => rects[Symbol.iterator](), + item: (index: number): DOMRect | null => rects[index] ?? null, + length: rects.length, + } + + rects.forEach((value, index) => { + Object.defineProperty(list, index, { value }) + }) + + return list +} + +const stubRangeRects = (rectsByElement: Map): void => { + let selected: Element | undefined + const range = document.createRange() + + rs.spyOn(range, 'detach').mockImplementation(() => undefined) + rs.spyOn(range, 'getClientRects').mockImplementation(() => + toDomRectList(selected ? (rectsByElement.get(selected) ?? []) : []), + ) + rs.spyOn(range, 'selectNodeContents').mockImplementation((node: Node) => { + selected = node instanceof Element ? node : undefined + }) + rs.spyOn(document, 'createRange').mockReturnValue(range) +} + function createCore(): { core: EntryViewTrackingCore trackView: ReturnType @@ -19,6 +54,14 @@ describe('EntryViewTracker', () => { beforeEach(() => { rs.useFakeTimers() + rs.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => + window.setTimeout(() => { + callback(Date.now()) + }, 0), + ) + rs.stubGlobal('cancelAnimationFrame', (id: number): void => { + window.clearTimeout(id) + }) document.body.innerHTML = '' }) @@ -64,6 +107,214 @@ describe('EntryViewTracker', () => { cleanup() }) + it('tracks a display:contents entry through its single rendered child', async () => { + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-single-child-view' + entry.style.display = 'contents' + const child = document.createElement('section') + entry.append(child) + document.body.append(entry) + + const { core, trackView } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0 }) + + const instance = io.getLast() + + if (!instance) { + throw new Error('IntersectionObserver polyfill instance not found') + } + + instance.trigger({ target: child, isIntersecting: true, intersectionRatio: 1 }) + + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(1) + expect(trackView).toHaveBeenCalledWith( + expect.objectContaining({ + componentId: 'entry-single-child-view', + viewId: expect.any(String), + viewDurationMs: 0, + }), + ) + + cleanup() + }) + + it('tracks a multi-child display:contents entry from aggregate child visibility', async () => { + const root = document.createElement('div') + setRect(root, rect(0, 0, 300, 100)) + document.body.append(root) + + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-aggregate-view' + entry.style.display = 'contents' + + const first = document.createElement('div') + const second = document.createElement('div') + entry.append(first, second) + root.append(entry) + + stubRangeRects( + new Map([[entry, [rect(0, 180, 100, 60), rect(160, 20, 100, 60)]]]), + ) + + const { core, trackView } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, root }) + + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(1) + expect(trackView).toHaveBeenCalledWith( + expect.objectContaining({ + componentId: 'entry-aggregate-view', + viewId: expect.any(String), + viewDurationMs: 0, + }), + ) + + cleanup() + }) + + it('uses virtual aggregate tracking for text display:contents entries', async () => { + const root = document.createElement('div') + setRect(root, rect(0, 0, 300, 100)) + document.body.append(root) + + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-text-view' + entry.style.display = 'contents' + entry.append('Plain text') + root.append(entry) + + stubRangeRects(new Map([[entry, [rect(10, 10, 120, 20)]]])) + + const { core, trackView } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, root }) + + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(1) + expect(trackView).toHaveBeenCalledWith( + expect.objectContaining({ + componentId: 'entry-text-view', + viewId: expect.any(String), + viewDurationMs: 0, + }), + ) + + cleanup() + }) + + it('emits periodic and final updates for virtual aggregate entries', async () => { + const root = document.createElement('div') + setRect(root, rect(0, 0, 300, 100)) + document.body.append(root) + + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-aggregate-duration' + entry.style.display = 'contents' + entry.append(document.createElement('div'), document.createElement('div')) + root.append(entry) + + const rectsByElement = new Map([[entry, [rect(10, 10, 100, 40)]]]) + stubRangeRects(rectsByElement) + + const { core, trackView } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, root, viewDurationUpdateIntervalMs: 1000 }) + + await advance(0) + await advance(1000) + + rectsByElement.set(entry, [rect(10, 140, 100, 40)]) + document.dispatchEvent(new Event('scroll')) + await advance(0) + await Promise.resolve() + await Promise.resolve() + + expect(trackView).toHaveBeenCalledTimes(3) + + const firstPayload = trackView.mock.calls[0]?.[0] + const secondPayload = trackView.mock.calls[1]?.[0] + const finalPayload = trackView.mock.calls[2]?.[0] + + expect(firstPayload).toEqual( + expect.objectContaining({ + componentId: 'entry-aggregate-duration', + viewDurationMs: 0, + }), + ) + expect(secondPayload).toEqual( + expect.objectContaining({ + componentId: 'entry-aggregate-duration', + viewId: firstPayload?.viewId, + viewDurationMs: 1000, + }), + ) + expect(finalPayload).toEqual( + expect.objectContaining({ + componentId: 'entry-aggregate-duration', + viewId: firstPayload?.viewId, + viewDurationMs: 1000, + }), + ) + + cleanup() + }) + + it('retargets display:contents entries after child mutations without duplicate tracking', async () => { + const root = document.createElement('div') + setRect(root, rect(0, 0, 300, 100)) + document.body.append(root) + + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-retarget-view' + entry.style.display = 'contents' + + const first = document.createElement('div') + entry.append(first) + root.append(entry) + + stubRangeRects(new Map([[entry, [rect(10, 10, 100, 40)]]])) + + const { core, trackView } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, root }) + + const instance = io.getLast() + + if (!instance) { + throw new Error('IntersectionObserver polyfill instance not found') + } + + const second = document.createElement('div') + entry.append(second) + await Promise.resolve() + await advance(0) + + instance.trigger({ target: first, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(1) + expect(trackView).toHaveBeenCalledWith( + expect.objectContaining({ + componentId: 'entry-retarget-view', + viewId: expect.any(String), + viewDurationMs: 0, + }), + ) + + cleanup() + }) + it('prefers manual data when manually observing an element', async () => { const element = document.createElement('section') document.body.append(element) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewLifecycle.ts b/packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewLifecycle.ts new file mode 100644 index 000000000..f0f6f100c --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewLifecycle.ts @@ -0,0 +1,85 @@ +import { CAN_ADD_LISTENERS, HAS_MUTATION_OBSERVER } from '../../../constants' +import { derefElement, type ElementState } from './element-view-observer-support' + +export const collectAffectedDisplayContentsStates = ( + records: readonly MutationRecord[], + states: ReadonlySet, +): Set => { + const affected = new Set() + + for (const record of records) { + for (const state of states) { + const element = derefElement(state) + if (!element) continue + + if (record.target === element || element.contains(record.target)) { + affected.add(state) + } + } + } + + return affected +} + +export const syncDisplayContentsMutationObserver = ( + current: MutationObserver | undefined, + states: ReadonlySet, + onRecords: (records: readonly MutationRecord[]) => void, +): MutationObserver | undefined => { + if (!HAS_MUTATION_OBSERVER) return current + + current?.disconnect() + + if (states.size === 0) return undefined + + const observer = + current ?? + new MutationObserver((records) => { + onRecords(records) + }) + + for (const state of states) { + const element = derefElement(state) + if (!element) continue + + observer.observe(element, { + attributeFilter: ['class', 'hidden', 'style'], + attributes: true, + childList: true, + subtree: true, + }) + } + + return observer +} + +export const syncVirtualMeasurementListeners = ( + current: (() => void) | undefined, + hasVirtualStates: boolean, + schedule: () => void, + cancel: () => void, +): (() => void) | undefined => { + if (!CAN_ADD_LISTENERS) return current + + if (!hasVirtualStates) { + current?.() + cancel() + return undefined + } + + if (current) return current + + const { visualViewport } = window + + window.addEventListener('resize', schedule) + document.addEventListener('scroll', schedule, true) + visualViewport?.addEventListener('resize', schedule) + visualViewport?.addEventListener('scroll', schedule) + + return (): void => { + window.removeEventListener('resize', schedule) + document.removeEventListener('scroll', schedule, true) + visualViewport?.removeEventListener('resize', schedule) + visualViewport?.removeEventListener('scroll', schedule) + } +} diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewSource.ts b/packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewSource.ts new file mode 100644 index 000000000..26983ac24 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/displayContentsViewSource.ts @@ -0,0 +1,230 @@ +import { CAN_ADD_LISTENERS } from '../../../constants' +import type { ElementState } from './element-view-observer-support' + +interface MarginValue { + readonly unit: '%' | 'px' + readonly value: number +} + +export interface ObservationSource { + readonly source: ElementState['source'] + readonly target: Element | null +} + +export interface RootMargin { + readonly bottom: MarginValue + readonly left: MarginValue + readonly right: MarginValue + readonly top: MarginValue +} + +interface RenderedTargets { + readonly hasText: boolean + readonly targets: Element[] +} + +interface VirtualVisibilityOptions { + readonly minVisibleRatio: number + readonly root: Element | Document | null + readonly rootMargin: RootMargin +} + +type ClipRect = Pick + +const ROOT_MARGIN_BOTTOM_INDEX = 2 +const ROOT_MARGIN_LEFT_INDEX = 3 +const ROOT_MARGIN_MAX_TOKENS = 4 +const ZERO_MARGIN: MarginValue = Object.freeze({ unit: 'px' as const, value: 0 }) +const ZERO_ROOT_MARGIN: RootMargin = Object.freeze({ + bottom: ZERO_MARGIN, + left: ZERO_MARGIN, + right: ZERO_MARGIN, + top: ZERO_MARGIN, +}) +const OVERFLOW_CLIP_RE = /(auto|scroll|hidden|clip)/ + +export const getElementDisplay = (element: Element): string => + CAN_ADD_LISTENERS && typeof getComputedStyle === 'function' + ? getComputedStyle(element).display + : '' + +export const isDisplayContentsElement = (element: Element): boolean => + getElementDisplay(element) === 'contents' + +const isNestedEntryElement = (element: Element): boolean => + element.hasAttribute('data-ctfl-entry-id') + +const hasVisibleText = (node: ChildNode): boolean => + node.nodeType === Node.TEXT_NODE && !!node.textContent?.trim() + +const collectRenderedTargets = (element: Element): RenderedTargets => { + const targets: Element[] = [] + let hasText = false + + const visit = (parent: ParentNode): void => { + parent.childNodes.forEach((node) => { + if (hasVisibleText(node)) { + hasText = true + return + } + + if (!(node instanceof Element)) return + if (isNestedEntryElement(node)) return + + const display = getElementDisplay(node) + if (display === 'none') return + + if (display === 'contents') { + visit(node) + return + } + + targets.push(node) + }) + } + + visit(element) + + return { hasText, targets } +} + +export const resolveObservationSource = (element: Element): ObservationSource => { + if (!isDisplayContentsElement(element)) { + return { source: 'element', target: element } + } + + const { hasText, targets } = collectRenderedTargets(element) + + if (!hasText && targets.length === 1) { + return { source: 'element', target: targets[0] ?? null } + } + + return { source: 'virtual', target: null } +} + +const parseMarginValue = (token: string): MarginValue | undefined => { + const match = /^(-?(?:\d+|\d*\.\d+))(px|%)$/.exec(token) + if (!match) return undefined + + const [, rawValue, unit] = match + const value = Number(rawValue) + + if (!Number.isFinite(value) || (unit !== 'px' && unit !== '%')) return undefined + + return { unit, value } +} + +export const parseRootMargin = (raw: string): RootMargin => { + const tokens = raw.trim().split(/\s+/).filter(Boolean) + if (tokens.length === 0 || tokens.length > ROOT_MARGIN_MAX_TOKENS) return ZERO_ROOT_MARGIN + + const values: MarginValue[] = [] + + for (const token of tokens) { + const value = parseMarginValue(token) + if (!value) return ZERO_ROOT_MARGIN + values.push(value) + } + + const top = values[0] ?? ZERO_MARGIN + const right = values[1] ?? top + const bottom = values[ROOT_MARGIN_BOTTOM_INDEX] ?? top + const left = values[ROOT_MARGIN_LEFT_INDEX] ?? right + + return { bottom, left, right, top } +} + +const resolveMarginValue = (margin: MarginValue, size: number): number => + margin.unit === '%' ? (margin.value / 100) * size : margin.value + +const intersectRects = (first: ClipRect, second: ClipRect): ClipRect => ({ + bottom: Math.min(first.bottom, second.bottom), + left: Math.max(first.left, second.left), + right: Math.min(first.right, second.right), + top: Math.max(first.top, second.top), +}) + +const toRect = (rect: DOMRectReadOnly): ClipRect => ({ + bottom: rect.bottom, + left: rect.left, + right: rect.right, + top: rect.top, +}) + +const rectArea = (rect: ClipRect): number => + Math.max(0, rect.right - rect.left) * Math.max(0, rect.bottom - rect.top) + +const resolveRootClipRect = ({ + root, + rootMargin, +}: Pick): ClipRect => { + const rootRect = + root instanceof Element + ? root.getBoundingClientRect() + : { + bottom: window.innerHeight, + left: 0, + right: window.innerWidth, + top: 0, + } + const height = rootRect.bottom - rootRect.top + const width = rootRect.right - rootRect.left + + return { + bottom: rootRect.bottom + resolveMarginValue(rootMargin.bottom, height), + left: rootRect.left - resolveMarginValue(rootMargin.left, width), + right: rootRect.right + resolveMarginValue(rootMargin.right, width), + top: rootRect.top - resolveMarginValue(rootMargin.top, height), + } +} + +const resolveClipRect = (element: Element, options: VirtualVisibilityOptions): ClipRect => { + let clip = resolveRootClipRect(options) + const root = options.root instanceof Element ? options.root : null + let { parentElement: current } = element + + while (current && current !== document.documentElement) { + if (root && current === root) break + + const style = getComputedStyle(current) + const clips = OVERFLOW_CLIP_RE.test(style.overflowX) || OVERFLOW_CLIP_RE.test(style.overflowY) + + if (clips) { + clip = intersectRects(clip, toRect(current.getBoundingClientRect())) + } + + const { parentElement } = current + current = parentElement + } + + return clip +} + +export const measureVirtualVisibility = ( + element: Element, + options: VirtualVisibilityOptions, +): boolean => { + if (typeof document.createRange !== 'function') return false + + const range = document.createRange() + range.selectNodeContents(element) + + const rects = Array.from(range.getClientRects()) + range.detach() + + if (rects.length === 0) return false + + const clip = resolveClipRect(element, options) + let totalArea = 0 + let visibleArea = 0 + + rects.forEach((rect) => { + const area = rect.width * rect.height + if (area <= 0) return + + totalArea += area + visibleArea += rectArea(intersectRects(toRect(rect), clip)) + }) + + return totalArea > 0 && visibleArea / totalArea >= options.minVisibleRatio +} diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.test.ts b/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.test.ts index b95343dd9..c6d9dc180 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.test.ts @@ -16,6 +16,8 @@ const defaultPerElOpts: PerElementEffectiveOptions = { const makeState = (overrides: Partial = {}): ElementState => ({ ref: null, strongRef: null, + source: 'element', + target: null, opts: defaultPerElOpts, data: undefined, accumulatedMs: 0, diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.ts b/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.ts index a8cf8f189..b3f71c267 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/element-view-observer-support.ts @@ -54,9 +54,13 @@ export type PerElementEffectiveOptions = Required< Pick > +export type ElementViewSource = 'element' | 'virtual' + export interface ElementState { ref: WeakRef | null strongRef: Element | null + source: ElementViewSource + target: Element | null opts: PerElementEffectiveOptions data?: unknown accumulatedMs: number @@ -69,3 +73,78 @@ export interface ElementState { lastKnownVisible: boolean pendingFinal: boolean } + +export const initElementViewObserverOptions = ( + options?: ElementViewObserverOptions, +): EffectiveObserverOptions => ({ + dwellTimeMs: Num.nonNeg(options?.dwellTimeMs, DEFAULTS.DWELL_MS), + viewDurationUpdateIntervalMs: Num.nonNeg( + options?.viewDurationUpdateIntervalMs, + DEFAULTS.VIEW_DURATION_UPDATE_INTERVAL_MS, + ), + minVisibleRatio: Num.clamp01(options?.minVisibleRatio, DEFAULTS.RATIO), + root: options?.root ?? null, + rootMargin: options?.rootMargin ?? '0px', +}) + +export const createElementState = ( + element: Element, + observerOptions: EffectiveObserverOptions, + elementOptions?: ElementViewElementOptions, +): ElementState => { + const opts: PerElementEffectiveOptions = { + dwellTimeMs: Num.nonNeg(elementOptions?.dwellTimeMs, observerOptions.dwellTimeMs), + viewDurationUpdateIntervalMs: Num.nonNeg( + elementOptions?.viewDurationUpdateIntervalMs, + observerOptions.viewDurationUpdateIntervalMs, + ), + } + + const hasWeakRef = typeof WeakRef === 'function' + + return { + ref: hasWeakRef ? new WeakRef(element) : null, + strongRef: hasWeakRef ? null : element, + source: 'element', + target: null, + opts, + data: elementOptions?.data, + accumulatedMs: 0, + visibleSince: null, + fireTimer: null, + attempts: 0, + viewId: null, + done: false, + inFlight: false, + lastKnownVisible: false, + pendingFinal: false, + } +} + +export const pauseVisibilityCycle = (state: ElementState, now: number): void => { + if (!state.lastKnownVisible) return + + if (state.visibleSince !== null) { + state.accumulatedMs += now - state.visibleSince + state.visibleSince = null + } + + clearFireTimer(state) +} + +export const resetVisibilityCycle = (state: ElementState): void => { + state.lastKnownVisible = false + state.pendingFinal = false + state.accumulatedMs = 0 + state.visibleSince = null + state.attempts = 0 + state.viewId = null + clearFireTimer(state) +} + +export const getRemainingMsUntilNextFire = (state: ElementState, elapsedMs: number): number => { + const requiredElapsedMs = + state.opts.dwellTimeMs + state.attempts * state.opts.viewDurationUpdateIntervalMs + + return requiredElapsedMs - elapsedMs +} diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/elementViewSourceController.ts b/packages/web/web-sdk/src/entry-tracking/events/view/elementViewSourceController.ts new file mode 100644 index 000000000..b1fbb014d --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/elementViewSourceController.ts @@ -0,0 +1,249 @@ +import { CAN_ADD_LISTENERS } from '../../../constants' +import { + collectAffectedDisplayContentsStates, + syncDisplayContentsMutationObserver, + syncVirtualMeasurementListeners, +} from './displayContentsViewLifecycle' +import { + type RootMargin, + isDisplayContentsElement, + measureVirtualVisibility, + parseRootMargin, + resolveObservationSource, +} from './displayContentsViewSource' +import { + type EffectiveObserverOptions, + type ElementState, + NOW, + derefElement, +} from './element-view-observer-support' + +type ScheduledTask = + | { + readonly frame: false + readonly id: ReturnType + } + | { + readonly frame: true + readonly id: number + } + +interface ElementViewSourceControllerHandlers { + readonly onDropped: (state: ElementState) => void + readonly onHidden: (state: ElementState, now: number) => void + readonly onVisible: (state: ElementState, now: number) => void + readonly sweep: () => void +} + +class ElementViewSourceController { + private readonly rootMargin: RootMargin + private readonly io: IntersectionObserver + private readonly opts: EffectiveObserverOptions + private readonly handlers: ElementViewSourceControllerHandlers + private readonly targetStates = new WeakMap>() + private readonly displayContentsStates = new Set() + private readonly virtualStates = new Set() + private cleanupVirtualListeners?: () => void + private mutationObserver?: MutationObserver + private virtualMeasureTask: ScheduledTask | null = null + + public constructor( + io: IntersectionObserver, + opts: EffectiveObserverOptions, + handlers: ElementViewSourceControllerHandlers, + ) { + this.io = io + this.opts = opts + this.handlers = handlers + this.rootMargin = parseRootMargin(opts.rootMargin) + } + + public apply(state: ElementState, resetVisibility: boolean): void { + const element = derefElement(state) + + if (!element) { + this.handlers.onDropped(state) + return + } + + const next = resolveObservationSource(element) + const sourceChanged = state.source !== next.source || state.target !== next.target + + if (!sourceChanged) { + if (state.source === 'virtual') this.scheduleVirtualMeasurement() + return + } + + if (resetVisibility && state.lastKnownVisible) { + this.handlers.onHidden(state, NOW()) + } + + this.removeSource(state) + + const { source, target } = next + state.source = source + state.target = target + + if (isDisplayContentsElement(element)) { + this.displayContentsStates.add(state) + } + + if (next.source === 'virtual') { + this.virtualStates.add(state) + this.scheduleVirtualMeasurement() + } else if (next.target) { + this.observeTarget(next.target, state) + } + + this.syncMutationObserver() + this.syncVirtualListeners() + } + + public remove(state: ElementState): void { + this.removeSource(state) + this.syncMutationObserver() + this.syncVirtualListeners() + } + + public getStatesForTarget(target: Element): ReadonlySet | undefined { + return this.targetStates.get(target) + } + + public requestVirtualMeasurement(): void { + this.scheduleVirtualMeasurement() + } + + public disconnect(): void { + this.cancelVirtualMeasurement() + this.displayContentsStates.clear() + this.virtualStates.clear() + this.mutationObserver?.disconnect() + this.mutationObserver = undefined + this.cleanupVirtualListeners?.() + this.cleanupVirtualListeners = undefined + } + + private removeSource(state: ElementState): void { + if (state.target) { + this.unobserveTarget(state.target, state) + state.target = null + } + + this.virtualStates.delete(state) + this.displayContentsStates.delete(state) + } + + private observeTarget(target: Element, state: ElementState): void { + let states = this.targetStates.get(target) + + if (!states) { + states = new Set() + this.targetStates.set(target, states) + this.io.observe(target) + } + + states.add(state) + } + + private unobserveTarget(target: Element, state: ElementState): void { + const states = this.targetStates.get(target) + if (!states) return + + states.delete(state) + + if (states.size > 0) return + + this.targetStates.delete(target) + this.io.unobserve(target) + } + + private scheduleVirtualMeasurement(): void { + if (this.virtualMeasureTask !== null || this.virtualStates.size === 0) return + + if (CAN_ADD_LISTENERS && typeof requestAnimationFrame === 'function') { + const id = requestAnimationFrame(() => { + this.virtualMeasureTask = null + this.measureVirtualStates() + }) + this.virtualMeasureTask = { frame: true, id } + return + } + + const id = setTimeout(() => { + this.virtualMeasureTask = null + this.measureVirtualStates() + }, 0) + this.virtualMeasureTask = { frame: false, id } + } + + private cancelVirtualMeasurement(): void { + const { virtualMeasureTask } = this + if (!virtualMeasureTask) return + + if (virtualMeasureTask.frame) { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(virtualMeasureTask.id) + } else { + clearTimeout(virtualMeasureTask.id) + } + + this.virtualMeasureTask = null + } + + private measureVirtualStates(): void { + if (this.virtualStates.size === 0) return + + const now = NOW() + + for (const state of this.virtualStates) { + if (state.done || state.source !== 'virtual') continue + + const element = derefElement(state) + + if (!element) { + this.handlers.onDropped(state) + continue + } + + const visible = measureVirtualVisibility(element, { + minVisibleRatio: this.opts.minVisibleRatio, + root: this.opts.root, + rootMargin: this.rootMargin, + }) + + if (visible) { + this.handlers.onVisible(state, now) + } else { + this.handlers.onHidden(state, now) + } + } + + this.handlers.sweep() + } + + private syncMutationObserver(): void { + this.mutationObserver = syncDisplayContentsMutationObserver( + this.mutationObserver, + this.displayContentsStates, + this.onDisplayContentsMutations.bind(this), + ) + } + + private onDisplayContentsMutations(records: readonly MutationRecord[]): void { + if (records.length === 0 || this.displayContentsStates.size === 0) return + + collectAffectedDisplayContentsStates(records, this.displayContentsStates).forEach((state) => { + this.apply(state, true) + }) + } + + private syncVirtualListeners(): void { + this.cleanupVirtualListeners = syncVirtualMeasurementListeners( + this.cleanupVirtualListeners, + this.virtualStates.size > 0, + this.scheduleVirtualMeasurement.bind(this), + this.cancelVirtualMeasurement.bind(this), + ) + } +} + +export default ElementViewSourceController