Skip to content
5 changes: 4 additions & 1 deletion implementations/react-web-sdk/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type JSX, useEffect, useMemo, useState } from 'react'
import { Link, Outlet, useOutletContext } from 'react-router-dom'
import { AnalyticsEventDisplay } from './components/AnalyticsEventDisplay'
import { ENTRY_IDS, LIVE_UPDATES_ENTRY_ID } from './config/entries'
import { HOME_PATH, PAGE_TWO_PATH } from './config/routes'
import { EXO_PATH, HOME_PATH, PAGE_TWO_PATH } from './config/routes'
import { fetchEntries, getContentfulConfigError } from './services/contentfulClient'
import type { ContentEntry } from './types/contentful'

Expand Down Expand Up @@ -138,6 +138,9 @@ export default function App(): JSX.Element {
<Link data-testid="link-home" to={HOME_PATH}>
Home
</Link>
<Link data-testid="link-exo" to={EXO_PATH}>
ExO
</Link>
<Link data-testid="link-page-two" to={PAGE_TWO_PATH}>
Go to Page Two
</Link>
Expand Down
261 changes: 261 additions & 0 deletions implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { useOptimizationContext, type OptimizationSdk } from '@contentful/optimization-react-web'
import type { JSX } from 'react'
import { useEffect, useState } from 'react'
import { isRecord } from '../utils/typeGuards'

interface NodeViewEventSummary {
entityId: string
entityKind: string
optimizationId: string
variant: string
viewDurationMs: number
viewId: string
}

interface BlockedNodeViewSummary {
method: string
reason: string
}

interface NodeViewDatasetSnapshot {
entityId: string | undefined
entityKind: string | undefined
nodeId: string | undefined
optimizationId: string | undefined
variant: string | undefined
}

interface NodeViewRuntimeSnapshot {
autoTrackNodeInteractionViews: boolean | undefined
matchingNodeElementsCount: number
runtimeStarted: boolean | undefined
}

const NODE_VIEW_SELECTOR = '[data-ctfl-node-id]'

function reflectGet(target: object, key: string): unknown {
return Reflect.get(target, key) as unknown
}

function isHtmlOrSvgElement(element: Element): element is HTMLElement | SVGElement {
if (typeof HTMLElement === 'undefined' || typeof SVGElement === 'undefined') {
return false
}

return element instanceof HTMLElement || element instanceof SVGElement
}

function readNodeViewTargetSnapshot(): NodeViewDatasetSnapshot | undefined {
const element = document.querySelector('[data-testid="node-view-target"]')
if (!element || !isHtmlOrSvgElement(element)) {
return undefined
}

const {
dataset: { ctflEntityId, ctflEntityKind, ctflNodeId, ctflOptimizationId, ctflVariant },
} = element

return {
entityId: ctflEntityId,
entityKind: ctflEntityKind,
nodeId: ctflNodeId,
optimizationId: ctflOptimizationId,
variant: ctflVariant,
}
}

function readRuntimeSnapshot(sdk: OptimizationSdk | undefined): NodeViewRuntimeSnapshot {
const matchingNodeElementsCount =
typeof document === 'undefined' ? 0 : document.querySelectorAll(NODE_VIEW_SELECTOR).length

if (!sdk) {
return {
autoTrackNodeInteractionViews: undefined,
matchingNodeElementsCount,
runtimeStarted: undefined,
}
}

const config = reflectGet(sdk, 'autoTrackNodeInteraction')
const views = config && typeof config === 'object' ? reflectGet(config, 'views') : undefined
const autoTrackNodeInteractionViews = typeof views === 'boolean' ? views : undefined

const runtime = reflectGet(sdk, 'nodeViewRuntime')
const runtimeStarted =
runtime && typeof runtime === 'object' ? reflectGet(runtime, 'detector') != null : undefined

return { autoTrackNodeInteractionViews, matchingNodeElementsCount, runtimeStarted }
}

function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}

function toNodeViewEvent(event: unknown): NodeViewEventSummary | undefined {
if (!isRecord(event) || event.type !== 'exo_view') return undefined

const entityId = asString(event.entityId)
const entityKind = asString(event.entityKind)
const optimizationId = asString(event.optimizationId)
const variant = asString(event.variant)
const viewId = asString(event.viewId)
const viewDurationMs = typeof event.viewDurationMs === 'number' ? event.viewDurationMs : undefined

if (
!entityId ||
!entityKind ||
!optimizationId ||
!variant ||
!viewId ||
viewDurationMs === undefined
) {
return undefined
}

return { entityId, entityKind, optimizationId, variant, viewId, viewDurationMs }
}

function toBlockedNodeViewSummary(event: unknown): BlockedNodeViewSummary | undefined {
if (!isRecord(event)) return undefined
const method = typeof event.method === 'string' ? event.method : undefined
const reason = typeof event.reason === 'string' ? event.reason : undefined
if (method !== 'trackNodeView' || reason === undefined) return undefined
return { method, reason }
}

interface NodeViewDebugState {
consent: boolean | undefined
latestBlockedNodeView: BlockedNodeViewSummary | undefined
latestNodeViewEvent: NodeViewEventSummary | undefined
nodeViewEventsSeen: number
profileId: string | undefined
runtimeSnapshot: NodeViewRuntimeSnapshot
targetSnapshot: NodeViewDatasetSnapshot | undefined
}

const INITIAL_RUNTIME_SNAPSHOT: NodeViewRuntimeSnapshot = {
autoTrackNodeInteractionViews: undefined,
matchingNodeElementsCount: 0,
runtimeStarted: undefined,
}

function useNodeViewDebugState(
sdk: OptimizationSdk | undefined,
isReady: boolean,
): NodeViewDebugState {
const [consent, setConsent] = useState<boolean | undefined>(undefined)
const [profileId, setProfileId] = useState<string | undefined>(undefined)
const [nodeViewEventsSeen, setNodeViewEventsSeen] = useState(0)
const [latestNodeViewEvent, setLatestNodeViewEvent] = useState<NodeViewEventSummary | undefined>(
undefined,
)
const [latestBlockedNodeView, setLatestBlockedNodeView] = useState<
BlockedNodeViewSummary | undefined
>(undefined)
const [targetSnapshot, setTargetSnapshot] = useState<NodeViewDatasetSnapshot | undefined>(
undefined,
)
const [runtimeSnapshot, setRuntimeSnapshot] =
useState<NodeViewRuntimeSnapshot>(INITIAL_RUNTIME_SNAPSHOT)

useEffect(() => {
if (!sdk || !isReady) {
setConsent(undefined)
setProfileId(undefined)
setNodeViewEventsSeen(0)
setLatestNodeViewEvent(undefined)
setLatestBlockedNodeView(undefined)
setTargetSnapshot(undefined)
setRuntimeSnapshot(INITIAL_RUNTIME_SNAPSHOT)
return
}

setTargetSnapshot(readNodeViewTargetSnapshot())
setRuntimeSnapshot(readRuntimeSnapshot(sdk))

const consentSub = sdk.states.consent.subscribe((value: boolean | undefined) => {
setConsent(value)
setRuntimeSnapshot(readRuntimeSnapshot(sdk))
})

const profileSub = sdk.states.profile.subscribe((value: unknown) => {
if (!isRecord(value) || typeof value.id !== 'string') {
setProfileId(undefined)
return
}
setProfileId(value.id)
})

const eventSub = sdk.states.eventStream.subscribe((event: unknown) => {
const nodeViewEvent = toNodeViewEvent(event)
if (!nodeViewEvent) return
setNodeViewEventsSeen((previous) => previous + 1)
setLatestNodeViewEvent(nodeViewEvent)
setTargetSnapshot(readNodeViewTargetSnapshot())
setRuntimeSnapshot(readRuntimeSnapshot(sdk))
})

const blockedSub = sdk.states.blockedEventStream.subscribe((event: unknown) => {
const blockedNodeView = toBlockedNodeViewSummary(event)
if (!blockedNodeView) return
setLatestBlockedNodeView(blockedNodeView)
})

return () => {
consentSub.unsubscribe()
profileSub.unsubscribe()
eventSub.unsubscribe()
blockedSub.unsubscribe()
}
}, [isReady, sdk])

return {
consent,
latestBlockedNodeView,
latestNodeViewEvent,
nodeViewEventsSeen,
profileId,
runtimeSnapshot,
targetSnapshot,
}
}

export function NodeViewDebugPanel(): JSX.Element {
const { isReady, sdk } = useOptimizationContext()
const {
consent,
latestBlockedNodeView,
latestNodeViewEvent,
nodeViewEventsSeen,
profileId,
runtimeSnapshot,
targetSnapshot,
} = useNodeViewDebugState(sdk, isReady)

return (
<section
style={{ border: '1px solid #ccc', borderRadius: 4, display: 'grid', gap: 8, padding: 12 }}
>
<h3>Node view debug panel</h3>
<p>Consent: {`${consent}`}</p>
<p>Profile ID: {profileId}</p>
<p>Node view events seen: {nodeViewEventsSeen}</p>
<p>Node target present: {`${targetSnapshot !== undefined}`}</p>
<p>Matching node elements: {runtimeSnapshot.matchingNodeElementsCount}</p>
<p>autoTrackNodeInteraction.views: {`${runtimeSnapshot.autoTrackNodeInteractionViews}`}</p>
<p>nodeViewRuntime started: {`${runtimeSnapshot.runtimeStarted}`}</p>
<p>nodeId: {targetSnapshot?.nodeId}</p>
<p>entityId: {targetSnapshot?.entityId}</p>
<p>entityKind: {targetSnapshot?.entityKind}</p>
<p>optimizationId: {targetSnapshot?.optimizationId}</p>
<p>variant: {targetSnapshot?.variant}</p>
<p>
Last blocked:{' '}
{latestBlockedNodeView && `${latestBlockedNodeView.method}:${latestBlockedNodeView.reason}`}
</p>
<p>Last exo_view viewId: {latestNodeViewEvent?.viewId}</p>
<p>Last exo_view duration: {latestNodeViewEvent?.viewDurationMs}ms</p>
<p>Insights events are queued by the SDK; network emission can lag behind event detection.</p>
</section>
)
}
1 change: 1 addition & 0 deletions implementations/react-web-sdk/src/config/routes.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const HOME_PATH = '/'
export const EXO_PATH = '/exo'
export const PAGE_TWO_PATH = '/page-two'
5 changes: 4 additions & 1 deletion implementations/react-web-sdk/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { type ReactElement, StrictMode, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom'
import App from './App'
import { HOME_PATH } from './config/routes'
import { EXO_PATH, HOME_PATH } from './config/routes'
import { ExoPage } from './pages/ExoPage'
import { HomePage } from './pages/HomePage'
import { PageTwoPage } from './pages/PageTwoPage'
import { getContentfulClient } from './services/contentfulClient'
Expand Down Expand Up @@ -66,6 +67,7 @@ function RootLayout(): ReactElement {
experienceBaseUrl: EXPERIENCE_BASE_URL,
}}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
autoTrackNodeInteraction={{ views: true }}
logLevel={resolveLogLevel()}
app={{
name: 'ContentfulOptimization SDK - React Web SDK Reference',
Expand All @@ -89,6 +91,7 @@ const router = createBrowserRouter([
element: <App />,
children: [
{ index: true, element: <HomePage /> },
{ path: EXO_PATH.slice(1), element: <ExoPage /> },
{ path: 'page-two', element: <PageTwoPage /> },
{ path: '*', element: <Navigate replace to={HOME_PATH} /> },
],
Expand Down
13 changes: 13 additions & 0 deletions implementations/react-web-sdk/src/pages/ExoPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { JSX } from 'react'
import { NodeViewDebugPanel } from '../components/NodeViewDebugPanel'
import { NodeViewTrackingSection } from '../sections/NodeViewTrackingSection'

export function ExoPage(): JSX.Element {
return (
<section data-testid="exo-page" style={{ display: 'grid', gap: 16 }}>
<h2>ExO Node View</h2>
<NodeViewTrackingSection />
<NodeViewDebugPanel />
</section>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { JSX } from 'react'
import { useState } from 'react'

function NestedFragment({ isRoot = false }: { isRoot?: boolean }): JSX.Element {
const [hasChild, setHasChild] = useState(false)

return (
<div
data-ctfl-node-id="demo-fragment-node"
data-ctfl-entity-id="demo-fragment"
data-ctfl-entity-kind="Fragment"
data-ctfl-optimization-id="demo-experience"
data-ctfl-variant="demo-fragment-variant"
data-ctfl-parent-experience-id="demo-experience"
data-testid={isRoot ? 'node-view-target' : undefined}
style={{
border: '1px dashed #777',
borderRadius: 4,
padding: 12,
display: 'grid',
gap: 8,
}}
>
<p>
<strong>Fragment node</strong>
</p>
{!hasChild && (
<button
onClick={() => {
setHasChild(true)
}}
type="button"
>
Add nested Fragment
</button>
)}
{hasChild && <NestedFragment />}
</div>
)
}

export function NodeViewTrackingSection(): JSX.Element {
return (
<section style={{ display: 'grid', gap: 8 }}>
<h2>Node View Tracking</h2>
<p>
Each tracked node emits an <code>exo_view</code> event. The Fragment carries a{' '}
<code>parentExperienceId</code> to preserve the Experience → Fragment hierarchy.
</p>

<div
data-ctfl-node-id="demo-experience-node"
data-ctfl-entity-id="demo-experience"
data-ctfl-entity-kind="Experience"
data-ctfl-optimization-id="demo-experience"
data-ctfl-variant="demo-experience-variant"
style={{ border: '1px solid #aaa', borderRadius: 4, padding: 12, display: 'grid', gap: 8 }}
>
<p>
<strong>Experience node</strong>
</p>

<NestedFragment isRoot />
</div>
</section>
)
}