From f9fd1635d95da5f1e4e8ddfd1d8d2b20a97fcf9f Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 09:29:54 +0200 Subject: [PATCH 1/5] feat(react-web-sdk-impl): add node view tracking section and debug panel Ports the node view debug setup from the EXA-1487 backup stash, cleaned up for this branch: fixes autoTrackNodeInteraction naming, removes redundant manual attribute spreading, and restructures NodeViewDebugPanel into sub-components to stay within complexity and line-count limits. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/NodeViewDebugPanel.tsx | 509 ++++++++++++++++++ implementations/react-web-sdk/src/main.tsx | 1 + .../react-web-sdk/src/pages/HomePage.tsx | 5 + .../src/sections/NodeViewTrackingSection.tsx | 43 ++ 4 files changed, 558 insertions(+) create mode 100644 implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx create mode 100644 implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx diff --git a/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx b/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx new file mode 100644 index 00000000..5b78e859 --- /dev/null +++ b/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx @@ -0,0 +1,509 @@ +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]' +const ZERO_DURATION_MS = 0 + +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 readAutoTrackNodeInteractionViews(sdk: OptimizationSdk): boolean | undefined { + const config = reflectGet(sdk, 'autoTrackNodeInteraction') + if (!config || typeof config !== 'object') return undefined + const views = reflectGet(config, 'views') + return typeof views === 'boolean' ? views : undefined +} + +function readRuntimeStarted(sdk: OptimizationSdk): boolean | undefined { + const runtime = reflectGet(sdk, 'nodeViewRuntime') + if (!runtime || typeof runtime !== 'object') return undefined + const detector = reflectGet(runtime, 'detector') + return detector !== null && detector !== undefined +} + +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, + } + } + + return { + autoTrackNodeInteractionViews: readAutoTrackNodeInteractionViews(sdk), + matchingNodeElementsCount, + runtimeStarted: readRuntimeStarted(sdk), + } +} + +function isKnownEntityKind(value: string): boolean { + return ( + value === 'Experience' || + value === 'Fragment' || + value === 'InlineFragment' || + value === 'InlineComponent' + ) +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function orNone(value: string | undefined): string { + return value ?? 'none' +} + +function formatTarget(snapshot: NodeViewDatasetSnapshot | undefined): Record { + return { + presence: snapshot ? 'yes' : 'no', + nodeId: orNone(snapshot?.nodeId), + entityId: orNone(snapshot?.entityId), + entityKind: orNone(snapshot?.entityKind), + optimizationId: orNone(snapshot?.optimizationId), + variant: orNone(snapshot?.variant), + } +} + +function formatNodeViewEvent(event: NodeViewEventSummary | undefined): { + viewId: string + viewDuration: number +} { + return { + viewId: orNone(event?.viewId), + viewDuration: event?.viewDurationMs ?? 0, + } +} + +function extractNodeViewStrings( + event: Record, +): + | Pick + | 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) + if (!entityId || !entityKind || !optimizationId || !variant || !viewId) return undefined + return { entityId, entityKind, optimizationId, variant, viewId } +} + +function extractNodeViewFields(event: Record): NodeViewEventSummary | undefined { + const strings = extractNodeViewStrings(event) + if (!strings) return undefined + const viewDurationMs = typeof event.viewDurationMs === 'number' ? event.viewDurationMs : undefined + if (viewDurationMs === undefined) return undefined + return { ...strings, viewDurationMs } +} + +function toNodeViewEvent(event: unknown): NodeViewEventSummary | undefined { + if (!isRecord(event) || event.type !== 'exo_view') return undefined + return extractNodeViewFields(event) +} + +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 } +} + +async function triggerManualNodeView( + sdk: OptimizationSdk | undefined, + snapshot: NodeViewDatasetSnapshot | undefined, +): Promise { + if ( + !snapshot?.entityId || + !snapshot.entityKind || + !snapshot.optimizationId || + !snapshot.variant + ) { + return 'Manual trigger skipped: node dataset is incomplete.' + } + + if (!isKnownEntityKind(snapshot.entityKind)) { + return `Manual trigger skipped: unknown entityKind "${snapshot.entityKind}".` + } + + if (!sdk) return 'Manual trigger skipped: SDK instance is unavailable.' + + const trackNodeView = reflectGet(sdk, 'trackNodeView') + if (typeof trackNodeView !== 'function') { + return 'Manual trigger skipped: sdk.trackNodeView() is unavailable.' + } + + await Promise.resolve( + trackNodeView.call(sdk, { + entityId: snapshot.entityId, + entityKind: snapshot.entityKind, + optimizationId: snapshot.optimizationId, + variant: snapshot.variant, + viewDurationMs: ZERO_DURATION_MS, + viewId: crypto.randomUUID(), + }) as unknown, + ) + + return 'Manual trackNodeView() call sent.' +} + +function restartNodeViewRuntime(sdk: OptimizationSdk | undefined): string { + if (!sdk) return 'Runtime restart skipped: SDK instance is unavailable.' + + const runtime = reflectGet(sdk, 'nodeViewRuntime') + if (!runtime || typeof runtime !== 'object') { + return 'Runtime restart skipped: nodeViewRuntime is unavailable.' + } + + const stop = reflectGet(runtime, 'stop') + const start = reflectGet(runtime, 'start') + if (typeof stop !== 'function' || typeof start !== 'function') { + return 'Runtime restart skipped: nodeViewRuntime start/stop are unavailable.' + } + + stop.call(runtime) + start.call(runtime) + + return 'nodeViewRuntime restarted.' +} + +interface NodeViewStatusDisplayProps { + consent: boolean | undefined + latestBlockedNodeView: BlockedNodeViewSummary | undefined + latestNodeViewEvent: NodeViewEventSummary | undefined + manualTriggerStatus: string + nodeViewEventsSeen: number + profileId: string | undefined + runtimeControlStatus: string + runtimeSnapshot: NodeViewRuntimeSnapshot + targetSnapshot: NodeViewDatasetSnapshot | undefined +} + +function NodeViewStatusDisplay({ + consent, + latestBlockedNodeView, + latestNodeViewEvent, + manualTriggerStatus, + nodeViewEventsSeen, + profileId, + runtimeControlStatus, + runtimeSnapshot, + targetSnapshot, +}: NodeViewStatusDisplayProps): JSX.Element { + const blockedSummary = latestBlockedNodeView + ? `${latestBlockedNodeView.method}:${latestBlockedNodeView.reason}` + : undefined + const target = formatTarget(targetSnapshot) + const event = formatNodeViewEvent(latestNodeViewEvent) + + return ( + <> +

Consent: {String(consent)}

+

Profile ID: {orNone(profileId)}

+

Node view events seen: {nodeViewEventsSeen}

+

Node target present: {target.presence}

+

+ Matching node elements: {runtimeSnapshot.matchingNodeElementsCount} +

+

+ autoTrackNodeInteraction.views: {String(runtimeSnapshot.autoTrackNodeInteractionViews)} +

+

+ nodeViewRuntime started: {String(runtimeSnapshot.runtimeStarted)} +

+

nodeId: {target.nodeId}

+

entityId: {target.entityId}

+

entityKind: {target.entityKind}

+

+ optimizationId: {target.optimizationId} +

+

variant: {target.variant}

+

Last blocked: {orNone(blockedSummary)}

+

Last exo_view viewId: {event.viewId}

+

+ Last exo_view duration: {event.viewDuration}ms +

+

Insights events are queued by the SDK; network emission can lag behind event detection.

+

Manual trigger: {manualTriggerStatus}

+

Runtime control: {runtimeControlStatus}

+ + ) +} + +interface NodeViewControlsProps { + sdk: OptimizationSdk | undefined + setManualTriggerStatus: (value: string) => void + setRuntimeControlStatus: (value: string) => void + setRuntimeSnapshot: (value: NodeViewRuntimeSnapshot) => void + setTargetSnapshot: (value: NodeViewDatasetSnapshot | undefined) => void +} + +function NodeViewControls({ + sdk, + setManualTriggerStatus, + setRuntimeControlStatus, + setRuntimeSnapshot, + setTargetSnapshot, +}: NodeViewControlsProps): JSX.Element { + return ( +
+ + + +
+ ) +} + +const INITIAL_RUNTIME_SNAPSHOT: NodeViewRuntimeSnapshot = { + autoTrackNodeInteractionViews: undefined, + matchingNodeElementsCount: 0, + runtimeStarted: undefined, +} + +interface NodeViewDebugState { + consent: boolean | undefined + latestBlockedNodeView: BlockedNodeViewSummary | undefined + latestNodeViewEvent: NodeViewEventSummary | undefined + manualTriggerStatus: string + nodeViewEventsSeen: number + profileId: string | undefined + runtimeControlStatus: string + runtimeSnapshot: NodeViewRuntimeSnapshot + setManualTriggerStatus: (value: string) => void + setRuntimeControlStatus: (value: string) => void + setRuntimeSnapshot: (value: NodeViewRuntimeSnapshot) => void + setTargetSnapshot: (value: NodeViewDatasetSnapshot | undefined) => void + targetSnapshot: NodeViewDatasetSnapshot | undefined +} + +function useNodeViewDebugState( + sdk: OptimizationSdk | undefined, + isReady: boolean, +): NodeViewDebugState { + const [consent, setConsent] = useState(undefined) + const [profileId, setProfileId] = useState(undefined) + const [nodeViewEventsSeen, setNodeViewEventsSeen] = useState(0) + const [latestNodeViewEvent, setLatestNodeViewEvent] = useState( + undefined, + ) + const [latestBlockedNodeView, setLatestBlockedNodeView] = useState< + BlockedNodeViewSummary | undefined + >(undefined) + const [targetSnapshot, setTargetSnapshot] = useState( + undefined, + ) + const [runtimeSnapshot, setRuntimeSnapshot] = + useState(INITIAL_RUNTIME_SNAPSHOT) + const [manualTriggerStatus, setManualTriggerStatus] = useState('No manual trigger yet.') + const [runtimeControlStatus, setRuntimeControlStatus] = useState('No runtime action yet.') + + useEffect(() => { + if (!sdk || !isReady) { + setConsent(undefined) + setProfileId(undefined) + setNodeViewEventsSeen(0) + setLatestNodeViewEvent(undefined) + setLatestBlockedNodeView(undefined) + setTargetSnapshot(undefined) + setRuntimeSnapshot(INITIAL_RUNTIME_SNAPSHOT) + setManualTriggerStatus('No manual trigger yet.') + setRuntimeControlStatus('No runtime action yet.') + 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, + manualTriggerStatus, + nodeViewEventsSeen, + profileId, + runtimeControlStatus, + runtimeSnapshot, + setManualTriggerStatus, + setRuntimeControlStatus, + setRuntimeSnapshot, + setTargetSnapshot, + targetSnapshot, + } +} + +export function NodeViewDebugPanel(): JSX.Element { + const { isReady, sdk } = useOptimizationContext() + const { + consent, + latestBlockedNodeView, + latestNodeViewEvent, + manualTriggerStatus, + nodeViewEventsSeen, + profileId, + runtimeControlStatus, + runtimeSnapshot, + setManualTriggerStatus, + setRuntimeControlStatus, + setRuntimeSnapshot, + setTargetSnapshot, + targetSnapshot, + } = useNodeViewDebugState(sdk, isReady) + + return ( +
+

Node view debug panel

+ + +
+ ) +} diff --git a/implementations/react-web-sdk/src/main.tsx b/implementations/react-web-sdk/src/main.tsx index 03c6378d..22f9909b 100644 --- a/implementations/react-web-sdk/src/main.tsx +++ b/implementations/react-web-sdk/src/main.tsx @@ -66,6 +66,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', diff --git a/implementations/react-web-sdk/src/pages/HomePage.tsx b/implementations/react-web-sdk/src/pages/HomePage.tsx index b8aeee14..e82eb13a 100644 --- a/implementations/react-web-sdk/src/pages/HomePage.tsx +++ b/implementations/react-web-sdk/src/pages/HomePage.tsx @@ -2,10 +2,12 @@ import { useLiveUpdates } from '@contentful/optimization-react-web' import type { JSX } from 'react' import { useOutletContext } from 'react-router-dom' import type { AppOutletContext } from '../App' +import { NodeViewDebugPanel } from '../components/NodeViewDebugPanel' import { AUTO_OBSERVED_ENTRY_IDS, MANUALLY_OBSERVED_ENTRY_IDS } from '../config/entries' import { type EntryClickScenario, ContentEntry } from '../sections/ContentEntry' import { LiveUpdatesExampleEntry } from '../sections/LiveUpdatesExampleEntry' import { NestedContentEntry } from '../sections/NestedContentEntry' +import { NodeViewTrackingSection } from '../sections/NodeViewTrackingSection' import type { ContentEntry as ContentEntryType } from '../types/contentful' const AUTO_OBSERVED_CLICK_SCENARIO_BY_ENTRY_ID: Readonly> = { @@ -164,6 +166,9 @@ export function HomePage(): JSX.Element {

{previewPanelVisible ? 'Open' : 'Closed'}

+ + +

Live Updates

diff --git a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx new file mode 100644 index 00000000..ffa00ad4 --- /dev/null +++ b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx @@ -0,0 +1,43 @@ +import { useOptimizedNode, type UseOptimizedNodeParams } from '@contentful/optimization-react-web' +import type { JSX } from 'react' + +const NODE_VIEW_DEMO_NODE_ID = 'home-node-view-demo-node' + +const NODE_VIEW_DEMO_SOURCE_MAP: UseOptimizedNodeParams['sourceMap'] = { + variants: [{ type: 'personalization', id: 'home-node-view-demo-variant' }], + layers: [{ kind: 'Experience', id: 'home-node-view-demo-experience', variants: [0] }], + nodes: { + [NODE_VIEW_DEMO_NODE_ID]: { + layers: [0], + scope: 0, + }, + }, +} + +export function NodeViewTrackingSection(): JSX.Element { + const { payload, ref } = useOptimizedNode({ + nodeId: NODE_VIEW_DEMO_NODE_ID, + sourceMap: NODE_VIEW_DEMO_SOURCE_MAP, + }) + + return ( +

+

Node View Tracking

+

+ This block uses useOptimizedNode to stamp node-view attributes for automatic{' '} + exo_view tracking. +

+
+

+ {payload + ? `${payload.entityKind}:${payload.entityId}:${payload.variant}` + : 'Node metadata unavailable'} +

+
+
+ ) +} From 0243c0ff92e0ca388be95f28d7cc3d2d1d3d823b Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 09:35:31 +0200 Subject: [PATCH 2/5] feat(react-web-sdk-impl): move node view debug to /exo route Extracts NodeViewTrackingSection and NodeViewDebugPanel from HomePage into a dedicated ExoPage at /exo, keeping the home page focused on entry tracking and live updates. Co-Authored-By: Claude Sonnet 4.6 --- implementations/react-web-sdk/src/App.tsx | 5 ++++- implementations/react-web-sdk/src/config/routes.ts | 1 + implementations/react-web-sdk/src/main.tsx | 4 +++- implementations/react-web-sdk/src/pages/ExoPage.tsx | 13 +++++++++++++ .../react-web-sdk/src/pages/HomePage.tsx | 5 ----- 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 implementations/react-web-sdk/src/pages/ExoPage.tsx diff --git a/implementations/react-web-sdk/src/App.tsx b/implementations/react-web-sdk/src/App.tsx index 27ef004c..c18cd362 100644 --- a/implementations/react-web-sdk/src/App.tsx +++ b/implementations/react-web-sdk/src/App.tsx @@ -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' @@ -138,6 +138,9 @@ export default function App(): JSX.Element { Home + + Exo + Go to Page Two diff --git a/implementations/react-web-sdk/src/config/routes.ts b/implementations/react-web-sdk/src/config/routes.ts index 27c95c63..516c92c8 100644 --- a/implementations/react-web-sdk/src/config/routes.ts +++ b/implementations/react-web-sdk/src/config/routes.ts @@ -1,2 +1,3 @@ export const HOME_PATH = '/' +export const EXO_PATH = '/exo' export const PAGE_TWO_PATH = '/page-two' diff --git a/implementations/react-web-sdk/src/main.tsx b/implementations/react-web-sdk/src/main.tsx index 22f9909b..16b1c450 100644 --- a/implementations/react-web-sdk/src/main.tsx +++ b/implementations/react-web-sdk/src/main.tsx @@ -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' @@ -90,6 +91,7 @@ const router = createBrowserRouter([ element: , children: [ { index: true, element: }, + { path: EXO_PATH.slice(1), element: }, { path: 'page-two', element: }, { path: '*', element: }, ], diff --git a/implementations/react-web-sdk/src/pages/ExoPage.tsx b/implementations/react-web-sdk/src/pages/ExoPage.tsx new file mode 100644 index 00000000..a12d0e52 --- /dev/null +++ b/implementations/react-web-sdk/src/pages/ExoPage.tsx @@ -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 ( +
+

Exo Node View

+ + +
+ ) +} diff --git a/implementations/react-web-sdk/src/pages/HomePage.tsx b/implementations/react-web-sdk/src/pages/HomePage.tsx index e82eb13a..b8aeee14 100644 --- a/implementations/react-web-sdk/src/pages/HomePage.tsx +++ b/implementations/react-web-sdk/src/pages/HomePage.tsx @@ -2,12 +2,10 @@ import { useLiveUpdates } from '@contentful/optimization-react-web' import type { JSX } from 'react' import { useOutletContext } from 'react-router-dom' import type { AppOutletContext } from '../App' -import { NodeViewDebugPanel } from '../components/NodeViewDebugPanel' import { AUTO_OBSERVED_ENTRY_IDS, MANUALLY_OBSERVED_ENTRY_IDS } from '../config/entries' import { type EntryClickScenario, ContentEntry } from '../sections/ContentEntry' import { LiveUpdatesExampleEntry } from '../sections/LiveUpdatesExampleEntry' import { NestedContentEntry } from '../sections/NestedContentEntry' -import { NodeViewTrackingSection } from '../sections/NodeViewTrackingSection' import type { ContentEntry as ContentEntryType } from '../types/contentful' const AUTO_OBSERVED_CLICK_SCENARIO_BY_ENTRY_ID: Readonly> = { @@ -166,9 +164,6 @@ export function HomePage(): JSX.Element {

{previewPanelVisible ? 'Open' : 'Closed'}

- - -

Live Updates

From 2550174502194eeeadb4514d03138bea19040963 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 09:42:59 +0200 Subject: [PATCH 3/5] feat(react-web-sdk-impl): add dynamic child controls to NodeViewTrackingSection Starts the tracked node as an empty shell; add/clear buttons let the user verify that the MutationObserver picks up dynamically added children. Co-Authored-By: Claude Sonnet 4.6 --- .../src/sections/NodeViewTrackingSection.tsx | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx index ffa00ad4..110c303d 100644 --- a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx +++ b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx @@ -1,5 +1,6 @@ import { useOptimizedNode, type UseOptimizedNodeParams } from '@contentful/optimization-react-web' import type { JSX } from 'react' +import { useState } from 'react' const NODE_VIEW_DEMO_NODE_ID = 'home-node-view-demo-node' @@ -19,6 +20,7 @@ export function NodeViewTrackingSection(): JSX.Element { nodeId: NODE_VIEW_DEMO_NODE_ID, sourceMap: NODE_VIEW_DEMO_SOURCE_MAP, }) + const [childCount, setChildCount] = useState(0) return (

@@ -27,16 +29,41 @@ export function NodeViewTrackingSection(): JSX.Element { This block uses useOptimizedNode to stamp node-view attributes for automatic{' '} exo_view tracking.

+

+ {payload + ? `${payload.entityKind}:${payload.entityId}:${payload.variant}` + : 'Node metadata unavailable'} +

+
+ + +
-

- {payload - ? `${payload.entityKind}:${payload.entityId}:${payload.variant}` - : 'Node metadata unavailable'} -

+ {Array.from({ length: childCount }, (_, i) => ( +

+ Child element {i + 1} +

+ ))}
) From e11e10f12472f3d5b4fa58b6dc34bb38193995ba Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 11:17:05 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(react-web-sdk-impl):=20demonstrate=20E?= =?UTF-8?q?xperience=E2=86=92Fragment=20hierarchy=20in=20NodeViewTrackingS?= =?UTF-8?q?ection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single flat node with nested Experience (outer) and Fragment (inner) nodes backed by a two-layer sourceMap. The Fragment carries a parentExperienceId in its exo_view payload, and child elements can be added dynamically to verify MutationObserver detection inside the Fragment node. Co-Authored-By: Claude Sonnet 4.6 --- .../src/sections/NodeViewTrackingSection.tsx | 125 ++++++++++++------ 1 file changed, 82 insertions(+), 43 deletions(-) diff --git a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx index 110c303d..6ca2b711 100644 --- a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx +++ b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx @@ -2,22 +2,42 @@ import { useOptimizedNode, type UseOptimizedNodeParams } from '@contentful/optim import type { JSX } from 'react' import { useState } from 'react' -const NODE_VIEW_DEMO_NODE_ID = 'home-node-view-demo-node' +const EXPERIENCE_NODE_ID = 'demo-experience-node' +const FRAGMENT_NODE_ID = 'demo-fragment-node' +// layers[0] = Fragment (leaf), layers[1] = Experience (root) const NODE_VIEW_DEMO_SOURCE_MAP: UseOptimizedNodeParams['sourceMap'] = { - variants: [{ type: 'personalization', id: 'home-node-view-demo-variant' }], - layers: [{ kind: 'Experience', id: 'home-node-view-demo-experience', variants: [0] }], + variants: [ + { type: 'personalization', id: 'demo-experience-variant' }, + { type: 'personalization', id: 'demo-fragment-variant' }, + ], + layers: [ + { kind: 'Fragment', id: 'demo-fragment', variants: [1] }, + { kind: 'Experience', id: 'demo-experience', variants: [0] }, + ], nodes: { - [NODE_VIEW_DEMO_NODE_ID]: { - layers: [0], - scope: 0, - }, + [EXPERIENCE_NODE_ID]: { layers: [1], scope: 1 }, + [FRAGMENT_NODE_ID]: { layers: [0, 1], scope: 0 }, }, } +function formatPayload( + payload: + | { entityKind: string; entityId: string; variant: string; parentExperienceId?: string } + | undefined, +): string { + if (!payload) return 'unavailable' + const base = `${payload.entityKind}:${payload.entityId}:${payload.variant}` + return payload.parentExperienceId ? `${base} (parent: ${payload.parentExperienceId})` : base +} + export function NodeViewTrackingSection(): JSX.Element { - const { payload, ref } = useOptimizedNode({ - nodeId: NODE_VIEW_DEMO_NODE_ID, + const { payload: experiencePayload, ref: experienceRef } = useOptimizedNode({ + nodeId: EXPERIENCE_NODE_ID, + sourceMap: NODE_VIEW_DEMO_SOURCE_MAP, + }) + const { payload: fragmentPayload, ref: fragmentRef } = useOptimizedNode({ + nodeId: FRAGMENT_NODE_ID, sourceMap: NODE_VIEW_DEMO_SOURCE_MAP, }) const [childCount, setChildCount] = useState(0) @@ -26,44 +46,63 @@ export function NodeViewTrackingSection(): JSX.Element {

Node View Tracking

- This block uses useOptimizedNode to stamp node-view attributes for automatic{' '} - exo_view tracking. + Each tracked node emits an exo_view event. The Fragment carries a{' '} + parentExperienceId to preserve the Experience → Fragment hierarchy.

-

- {payload - ? `${payload.entityKind}:${payload.entityId}:${payload.variant}` - : 'Node metadata unavailable'} -

-
- - -
+
- {Array.from({ length: childCount }, (_, i) => ( -

- Child element {i + 1} +

+ Experience node +

+

{formatPayload(experiencePayload)}

+ +
+

+ Fragment node

- ))} +

{formatPayload(fragmentPayload)}

+ +
+ + +
+ + {Array.from({ length: childCount }, (_, i) => ( +

+ Child element {i + 1} +

+ ))} +
) From cdc66c5c0583dc71f6fe488d76ebc8925e24b348 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 15:25:14 +0200 Subject: [PATCH 5/5] refactor(react-web-sdk-impl): simplify NodeView debug panel and switch tracking to data attributes Replace useOptimizedNode with direct data-ctfl-* attributes consumed by autoTrackNodeInteraction, add recursive NestedFragment to demonstrate Fragment hierarchy, and simplify NodeViewDebugPanel by removing helpers, controls, and sub-components. Co-Authored-By: Claude Sonnet 4.6 --- implementations/react-web-sdk/src/App.tsx | 2 +- .../src/components/NodeViewDebugPanel.tsx | 332 +++--------------- .../react-web-sdk/src/pages/ExoPage.tsx | 2 +- .../src/sections/NodeViewTrackingSection.tsx | 126 +++---- 4 files changed, 86 insertions(+), 376 deletions(-) diff --git a/implementations/react-web-sdk/src/App.tsx b/implementations/react-web-sdk/src/App.tsx index c18cd362..28d3b59b 100644 --- a/implementations/react-web-sdk/src/App.tsx +++ b/implementations/react-web-sdk/src/App.tsx @@ -139,7 +139,7 @@ export default function App(): JSX.Element { Home - Exo + ExO Go to Page Two diff --git a/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx b/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx index 5b78e859..e983359b 100644 --- a/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx +++ b/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx @@ -32,7 +32,6 @@ interface NodeViewRuntimeSnapshot { } const NODE_VIEW_SELECTOR = '[data-ctfl-node-id]' -const ZERO_DURATION_MS = 0 function reflectGet(target: object, key: string): unknown { return Reflect.get(target, key) as unknown @@ -65,20 +64,6 @@ function readNodeViewTargetSnapshot(): NodeViewDatasetSnapshot | undefined { } } -function readAutoTrackNodeInteractionViews(sdk: OptimizationSdk): boolean | undefined { - const config = reflectGet(sdk, 'autoTrackNodeInteraction') - if (!config || typeof config !== 'object') return undefined - const views = reflectGet(config, 'views') - return typeof views === 'boolean' ? views : undefined -} - -function readRuntimeStarted(sdk: OptimizationSdk): boolean | undefined { - const runtime = reflectGet(sdk, 'nodeViewRuntime') - if (!runtime || typeof runtime !== 'object') return undefined - const detector = reflectGet(runtime, 'detector') - return detector !== null && detector !== undefined -} - function readRuntimeSnapshot(sdk: OptimizationSdk | undefined): NodeViewRuntimeSnapshot { const matchingNodeElementsCount = typeof document === 'undefined' ? 0 : document.querySelectorAll(NODE_VIEW_SELECTOR).length @@ -91,76 +76,43 @@ function readRuntimeSnapshot(sdk: OptimizationSdk | undefined): NodeViewRuntimeS } } - return { - autoTrackNodeInteractionViews: readAutoTrackNodeInteractionViews(sdk), - matchingNodeElementsCount, - runtimeStarted: readRuntimeStarted(sdk), - } -} + const config = reflectGet(sdk, 'autoTrackNodeInteraction') + const views = config && typeof config === 'object' ? reflectGet(config, 'views') : undefined + const autoTrackNodeInteractionViews = typeof views === 'boolean' ? views : undefined -function isKnownEntityKind(value: string): boolean { - return ( - value === 'Experience' || - value === 'Fragment' || - value === 'InlineFragment' || - value === 'InlineComponent' - ) + 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 orNone(value: string | undefined): string { - return value ?? 'none' -} - -function formatTarget(snapshot: NodeViewDatasetSnapshot | undefined): Record { - return { - presence: snapshot ? 'yes' : 'no', - nodeId: orNone(snapshot?.nodeId), - entityId: orNone(snapshot?.entityId), - entityKind: orNone(snapshot?.entityKind), - optimizationId: orNone(snapshot?.optimizationId), - variant: orNone(snapshot?.variant), - } -} - -function formatNodeViewEvent(event: NodeViewEventSummary | undefined): { - viewId: string - viewDuration: number -} { - return { - viewId: orNone(event?.viewId), - viewDuration: event?.viewDurationMs ?? 0, - } -} +function toNodeViewEvent(event: unknown): NodeViewEventSummary | undefined { + if (!isRecord(event) || event.type !== 'exo_view') return undefined -function extractNodeViewStrings( - event: Record, -): - | Pick - | 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) - if (!entityId || !entityKind || !optimizationId || !variant || !viewId) return undefined - return { entityId, entityKind, optimizationId, variant, viewId } -} - -function extractNodeViewFields(event: Record): NodeViewEventSummary | undefined { - const strings = extractNodeViewStrings(event) - if (!strings) return undefined const viewDurationMs = typeof event.viewDurationMs === 'number' ? event.viewDurationMs : undefined - if (viewDurationMs === undefined) return undefined - return { ...strings, viewDurationMs } -} -function toNodeViewEvent(event: unknown): NodeViewEventSummary | undefined { - if (!isRecord(event) || event.type !== 'exo_view') return undefined - return extractNodeViewFields(event) + if ( + !entityId || + !entityKind || + !optimizationId || + !variant || + !viewId || + viewDurationMs === undefined + ) { + return undefined + } + + return { entityId, entityKind, optimizationId, variant, viewId, viewDurationMs } } function toBlockedNodeViewSummary(event: unknown): BlockedNodeViewSummary | undefined { @@ -171,206 +123,22 @@ function toBlockedNodeViewSummary(event: unknown): BlockedNodeViewSummary | unde return { method, reason } } -async function triggerManualNodeView( - sdk: OptimizationSdk | undefined, - snapshot: NodeViewDatasetSnapshot | undefined, -): Promise { - if ( - !snapshot?.entityId || - !snapshot.entityKind || - !snapshot.optimizationId || - !snapshot.variant - ) { - return 'Manual trigger skipped: node dataset is incomplete.' - } - - if (!isKnownEntityKind(snapshot.entityKind)) { - return `Manual trigger skipped: unknown entityKind "${snapshot.entityKind}".` - } - - if (!sdk) return 'Manual trigger skipped: SDK instance is unavailable.' - - const trackNodeView = reflectGet(sdk, 'trackNodeView') - if (typeof trackNodeView !== 'function') { - return 'Manual trigger skipped: sdk.trackNodeView() is unavailable.' - } - - await Promise.resolve( - trackNodeView.call(sdk, { - entityId: snapshot.entityId, - entityKind: snapshot.entityKind, - optimizationId: snapshot.optimizationId, - variant: snapshot.variant, - viewDurationMs: ZERO_DURATION_MS, - viewId: crypto.randomUUID(), - }) as unknown, - ) - - return 'Manual trackNodeView() call sent.' -} - -function restartNodeViewRuntime(sdk: OptimizationSdk | undefined): string { - if (!sdk) return 'Runtime restart skipped: SDK instance is unavailable.' - - const runtime = reflectGet(sdk, 'nodeViewRuntime') - if (!runtime || typeof runtime !== 'object') { - return 'Runtime restart skipped: nodeViewRuntime is unavailable.' - } - - const stop = reflectGet(runtime, 'stop') - const start = reflectGet(runtime, 'start') - if (typeof stop !== 'function' || typeof start !== 'function') { - return 'Runtime restart skipped: nodeViewRuntime start/stop are unavailable.' - } - - stop.call(runtime) - start.call(runtime) - - return 'nodeViewRuntime restarted.' -} - -interface NodeViewStatusDisplayProps { +interface NodeViewDebugState { consent: boolean | undefined latestBlockedNodeView: BlockedNodeViewSummary | undefined latestNodeViewEvent: NodeViewEventSummary | undefined - manualTriggerStatus: string nodeViewEventsSeen: number profileId: string | undefined - runtimeControlStatus: string runtimeSnapshot: NodeViewRuntimeSnapshot targetSnapshot: NodeViewDatasetSnapshot | undefined } -function NodeViewStatusDisplay({ - consent, - latestBlockedNodeView, - latestNodeViewEvent, - manualTriggerStatus, - nodeViewEventsSeen, - profileId, - runtimeControlStatus, - runtimeSnapshot, - targetSnapshot, -}: NodeViewStatusDisplayProps): JSX.Element { - const blockedSummary = latestBlockedNodeView - ? `${latestBlockedNodeView.method}:${latestBlockedNodeView.reason}` - : undefined - const target = formatTarget(targetSnapshot) - const event = formatNodeViewEvent(latestNodeViewEvent) - - return ( - <> -

Consent: {String(consent)}

-

Profile ID: {orNone(profileId)}

-

Node view events seen: {nodeViewEventsSeen}

-

Node target present: {target.presence}

-

- Matching node elements: {runtimeSnapshot.matchingNodeElementsCount} -

-

- autoTrackNodeInteraction.views: {String(runtimeSnapshot.autoTrackNodeInteractionViews)} -

-

- nodeViewRuntime started: {String(runtimeSnapshot.runtimeStarted)} -

-

nodeId: {target.nodeId}

-

entityId: {target.entityId}

-

entityKind: {target.entityKind}

-

- optimizationId: {target.optimizationId} -

-

variant: {target.variant}

-

Last blocked: {orNone(blockedSummary)}

-

Last exo_view viewId: {event.viewId}

-

- Last exo_view duration: {event.viewDuration}ms -

-

Insights events are queued by the SDK; network emission can lag behind event detection.

-

Manual trigger: {manualTriggerStatus}

-

Runtime control: {runtimeControlStatus}

- - ) -} - -interface NodeViewControlsProps { - sdk: OptimizationSdk | undefined - setManualTriggerStatus: (value: string) => void - setRuntimeControlStatus: (value: string) => void - setRuntimeSnapshot: (value: NodeViewRuntimeSnapshot) => void - setTargetSnapshot: (value: NodeViewDatasetSnapshot | undefined) => void -} - -function NodeViewControls({ - sdk, - setManualTriggerStatus, - setRuntimeControlStatus, - setRuntimeSnapshot, - setTargetSnapshot, -}: NodeViewControlsProps): JSX.Element { - return ( -
- - - -
- ) -} - const INITIAL_RUNTIME_SNAPSHOT: NodeViewRuntimeSnapshot = { autoTrackNodeInteractionViews: undefined, matchingNodeElementsCount: 0, runtimeStarted: undefined, } -interface NodeViewDebugState { - consent: boolean | undefined - latestBlockedNodeView: BlockedNodeViewSummary | undefined - latestNodeViewEvent: NodeViewEventSummary | undefined - manualTriggerStatus: string - nodeViewEventsSeen: number - profileId: string | undefined - runtimeControlStatus: string - runtimeSnapshot: NodeViewRuntimeSnapshot - setManualTriggerStatus: (value: string) => void - setRuntimeControlStatus: (value: string) => void - setRuntimeSnapshot: (value: NodeViewRuntimeSnapshot) => void - setTargetSnapshot: (value: NodeViewDatasetSnapshot | undefined) => void - targetSnapshot: NodeViewDatasetSnapshot | undefined -} - function useNodeViewDebugState( sdk: OptimizationSdk | undefined, isReady: boolean, @@ -389,8 +157,6 @@ function useNodeViewDebugState( ) const [runtimeSnapshot, setRuntimeSnapshot] = useState(INITIAL_RUNTIME_SNAPSHOT) - const [manualTriggerStatus, setManualTriggerStatus] = useState('No manual trigger yet.') - const [runtimeControlStatus, setRuntimeControlStatus] = useState('No runtime action yet.') useEffect(() => { if (!sdk || !isReady) { @@ -401,8 +167,6 @@ function useNodeViewDebugState( setLatestBlockedNodeView(undefined) setTargetSnapshot(undefined) setRuntimeSnapshot(INITIAL_RUNTIME_SNAPSHOT) - setManualTriggerStatus('No manual trigger yet.') - setRuntimeControlStatus('No runtime action yet.') return } @@ -449,15 +213,9 @@ function useNodeViewDebugState( consent, latestBlockedNodeView, latestNodeViewEvent, - manualTriggerStatus, nodeViewEventsSeen, profileId, - runtimeControlStatus, runtimeSnapshot, - setManualTriggerStatus, - setRuntimeControlStatus, - setRuntimeSnapshot, - setTargetSnapshot, targetSnapshot, } } @@ -468,42 +226,36 @@ export function NodeViewDebugPanel(): JSX.Element { consent, latestBlockedNodeView, latestNodeViewEvent, - manualTriggerStatus, nodeViewEventsSeen, profileId, - runtimeControlStatus, runtimeSnapshot, - setManualTriggerStatus, - setRuntimeControlStatus, - setRuntimeSnapshot, - setTargetSnapshot, targetSnapshot, } = useNodeViewDebugState(sdk, isReady) return (

Node view debug panel

- - +

Consent: {`${consent}`}

+

Profile ID: {profileId}

+

Node view events seen: {nodeViewEventsSeen}

+

Node target present: {`${targetSnapshot !== undefined}`}

+

Matching node elements: {runtimeSnapshot.matchingNodeElementsCount}

+

autoTrackNodeInteraction.views: {`${runtimeSnapshot.autoTrackNodeInteractionViews}`}

+

nodeViewRuntime started: {`${runtimeSnapshot.runtimeStarted}`}

+

nodeId: {targetSnapshot?.nodeId}

+

entityId: {targetSnapshot?.entityId}

+

entityKind: {targetSnapshot?.entityKind}

+

optimizationId: {targetSnapshot?.optimizationId}

+

variant: {targetSnapshot?.variant}

+

+ Last blocked:{' '} + {latestBlockedNodeView && `${latestBlockedNodeView.method}:${latestBlockedNodeView.reason}`} +

+

Last exo_view viewId: {latestNodeViewEvent?.viewId}

+

Last exo_view duration: {latestNodeViewEvent?.viewDurationMs}ms

+

Insights events are queued by the SDK; network emission can lag behind event detection.

) } diff --git a/implementations/react-web-sdk/src/pages/ExoPage.tsx b/implementations/react-web-sdk/src/pages/ExoPage.tsx index a12d0e52..ba443909 100644 --- a/implementations/react-web-sdk/src/pages/ExoPage.tsx +++ b/implementations/react-web-sdk/src/pages/ExoPage.tsx @@ -5,7 +5,7 @@ import { NodeViewTrackingSection } from '../sections/NodeViewTrackingSection' export function ExoPage(): JSX.Element { return (
-

Exo Node View

+

ExO Node View

diff --git a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx index 6ca2b711..2cb3b5bd 100644 --- a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx +++ b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx @@ -1,49 +1,47 @@ -import { useOptimizedNode, type UseOptimizedNodeParams } from '@contentful/optimization-react-web' import type { JSX } from 'react' import { useState } from 'react' -const EXPERIENCE_NODE_ID = 'demo-experience-node' -const FRAGMENT_NODE_ID = 'demo-fragment-node' +function NestedFragment({ isRoot = false }: { isRoot?: boolean }): JSX.Element { + const [hasChild, setHasChild] = useState(false) -// layers[0] = Fragment (leaf), layers[1] = Experience (root) -const NODE_VIEW_DEMO_SOURCE_MAP: UseOptimizedNodeParams['sourceMap'] = { - variants: [ - { type: 'personalization', id: 'demo-experience-variant' }, - { type: 'personalization', id: 'demo-fragment-variant' }, - ], - layers: [ - { kind: 'Fragment', id: 'demo-fragment', variants: [1] }, - { kind: 'Experience', id: 'demo-experience', variants: [0] }, - ], - nodes: { - [EXPERIENCE_NODE_ID]: { layers: [1], scope: 1 }, - [FRAGMENT_NODE_ID]: { layers: [0, 1], scope: 0 }, - }, -} - -function formatPayload( - payload: - | { entityKind: string; entityId: string; variant: string; parentExperienceId?: string } - | undefined, -): string { - if (!payload) return 'unavailable' - const base = `${payload.entityKind}:${payload.entityId}:${payload.variant}` - return payload.parentExperienceId ? `${base} (parent: ${payload.parentExperienceId})` : base + return ( +
+

+ Fragment node +

+ {!hasChild && ( + + )} + {hasChild && } +
+ ) } export function NodeViewTrackingSection(): JSX.Element { - const { payload: experiencePayload, ref: experienceRef } = useOptimizedNode({ - nodeId: EXPERIENCE_NODE_ID, - sourceMap: NODE_VIEW_DEMO_SOURCE_MAP, - }) - const { payload: fragmentPayload, ref: fragmentRef } = useOptimizedNode({ - nodeId: FRAGMENT_NODE_ID, - sourceMap: NODE_VIEW_DEMO_SOURCE_MAP, - }) - const [childCount, setChildCount] = useState(0) - return ( -
+

Node View Tracking

Each tracked node emits an exo_view event. The Fragment carries a{' '} @@ -51,58 +49,18 @@ export function NodeViewTrackingSection(): JSX.Element {

Experience node

-

{formatPayload(experiencePayload)}

- -
-

- Fragment node -

-

{formatPayload(fragmentPayload)}

- -
- - -
- {Array.from({ length: childCount }, (_, i) => ( -

- Child element {i + 1} -

- ))} -
+
)