From 28ef3fc5682b906d05c429d967876e622ade3730 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:00:24 +0200 Subject: [PATCH 01/16] feat(api-schemas): add sourceMap and exo_view schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SourceMap, SourceMapLayer, SourceMapNode and SourceMapVariant schemas representing the XDA extensions.sourceMap structure. Add NodeViewEvent (type: 'exo_view') extending UniversalEventProperties with required node fields (entityId, entityKind, variant, optimizationId, viewId, viewDurationMs) and optional brief fields (entityKindId, entryIds, layers, parameters, parentExperienceId). Introduce ExoNodeLayer as the resolved layer shape for exo_* events (entityKind, entityId, variant?, optimizationId?) — distinct from the raw SourceMapLayer which carries unresolved indices. Register NodeViewEvent in the InsightsEvent discriminated union. --- .../api-schemas/src/experience/index.ts | 1 + .../experience/sourceMap/SourceMap.test.ts | 56 ++++++++ .../src/experience/sourceMap/SourceMap.ts | 117 +++++++++++++++++ .../src/experience/sourceMap/index.ts | 1 + .../src/insights/event/InsightsEvent.ts | 10 +- .../src/insights/event/NodeViewEvent.test.ts | 76 +++++++++++ .../src/insights/event/NodeViewEvent.ts | 123 ++++++++++++++++++ .../api-schemas/src/insights/event/index.ts | 1 + 8 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 packages/universal/api-schemas/src/experience/sourceMap/SourceMap.test.ts create mode 100644 packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts create mode 100644 packages/universal/api-schemas/src/experience/sourceMap/index.ts create mode 100644 packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts create mode 100644 packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts diff --git a/packages/universal/api-schemas/src/experience/index.ts b/packages/universal/api-schemas/src/experience/index.ts index 0b885a5a..495b7591 100644 --- a/packages/universal/api-schemas/src/experience/index.ts +++ b/packages/universal/api-schemas/src/experience/index.ts @@ -7,3 +7,4 @@ export * from './ExperienceResponse' export * from './optimization' export * from './profile' export * from './ResponseEnvelope' +export * from './sourceMap' diff --git a/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.test.ts b/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.test.ts new file mode 100644 index 00000000..21d161ea --- /dev/null +++ b/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from '@rstest/core' +import { SourceMap } from './SourceMap' + +const VALID_SOURCE_MAP = { + variants: [ + { type: 'personalization', id: 'default' }, + { type: 'personalization', id: 'variant-a' }, + ], + layers: [ + { kind: 'Experience', id: 'exp-id', variants: [1] }, + { kind: 'Fragment', id: 'frag-id' }, + ], + nodes: { + 'node-1': { layers: [0], scope: 0 }, + 'node-2': { layers: [1, 0], scope: 1 }, + }, +} + +describe('SourceMap schema', () => { + it('accepts a valid sourceMap', () => { + const result = SourceMap.safeParse(VALID_SOURCE_MAP) + + expect(result.success).toBe(true) + }) + + it('accepts layers without variants', () => { + const result = SourceMap.safeParse({ + ...VALID_SOURCE_MAP, + layers: [{ kind: 'Fragment', id: 'frag-id' }], + }) + + expect(result.success).toBe(true) + }) + + it('rejects missing nodes map', () => { + const { nodes: _removed, ...withoutNodes } = VALID_SOURCE_MAP + const result = SourceMap.safeParse(withoutNodes) + + expect(result.success).toBe(false) + }) + + it('rejects node missing scope', () => { + const result = SourceMap.safeParse({ + ...VALID_SOURCE_MAP, + nodes: { 'node-1': { layers: [0] } }, + }) + + expect(result.success).toBe(false) + }) + + it('accepts an empty variants array', () => { + const result = SourceMap.safeParse({ ...VALID_SOURCE_MAP, variants: [] }) + + expect(result.success).toBe(true) + }) +}) diff --git a/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts b/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts new file mode 100644 index 00000000..ac329e12 --- /dev/null +++ b/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts @@ -0,0 +1,117 @@ +import * as z from 'zod/mini' + +/** + * A single variant entry from the XDA `extensions.sourceMap.variants` array. + * + * @public + */ +export const SourceMapVariant = z.object({ + /** + * Variant category, e.g. `'personalization'`. + */ + type: z.string(), + /** + * Variant identifier, e.g. `'default'` or a variant sys.id. + */ + id: z.string(), +}) + +/** + * TypeScript type inferred from {@link SourceMapVariant}. + * + * @public + */ +export type SourceMapVariant = z.infer + +/** + * A structural layer from the XDA `extensions.sourceMap.layers` array. + * + * @public + */ +export const SourceMapLayer = z.object({ + /** + * Structural kind of the layer. + * + * @remarks + * Possible values include `'Experience'`, `'Fragment'`, + * `'InlineFragment'`, and `'InlineComponent'`. + */ + kind: z.string(), + /** + * Contentful sys.id of the Experience or Fragment entry this layer + * represents. + */ + id: z.string(), + /** + * Optional indices into `SourceMap.variants[]` that apply to this layer. + * + * @remarks + * Present only on layers that correspond to an optimization target. + */ + variants: z.optional(z.array(z.number())), +}) + +/** + * TypeScript type inferred from {@link SourceMapLayer}. + * + * @public + */ +export type SourceMapLayer = z.infer + +/** + * Metadata for a single rendered node from the XDA + * `extensions.sourceMap.nodes` map. + * + * @public + */ +export const SourceMapNode = z.object({ + /** + * Leaf-to-root indices into `SourceMap.layers[]` for this node. + */ + layers: z.array(z.number()), + /** + * Index of the nearest ancestor Fragment or Experience layer in + * `SourceMap.layers[]`. + */ + scope: z.number(), +}) + +/** + * TypeScript type inferred from {@link SourceMapNode}. + * + * @public + */ +export type SourceMapNode = z.infer + +/** + * Zod schema for the `extensions.sourceMap` object returned in XDA + * responses. + * + * @remarks + * The sourceMap provides structural context for each rendered node, + * enabling the SDK to resolve entity identity and variant selection + * without additional server round-trips. + * + * @public + */ +export const SourceMap = z.object({ + /** + * Flat list of variant entries referenced by layers. + */ + variants: z.array(SourceMapVariant), + /** + * Flat list of structural layers ordered leaf-to-root. + */ + layers: z.array(SourceMapLayer), + /** + * Map from rendered node ID to node metadata. + */ + nodes: z.record(z.string(), SourceMapNode), +}) + +/** + * TypeScript type inferred from {@link SourceMap}. + * + * @public + */ +export type SourceMap = z.infer diff --git a/packages/universal/api-schemas/src/experience/sourceMap/index.ts b/packages/universal/api-schemas/src/experience/sourceMap/index.ts new file mode 100644 index 00000000..c9f5e6b4 --- /dev/null +++ b/packages/universal/api-schemas/src/experience/sourceMap/index.ts @@ -0,0 +1 @@ +export * from './SourceMap' diff --git a/packages/universal/api-schemas/src/insights/event/InsightsEvent.ts b/packages/universal/api-schemas/src/insights/event/InsightsEvent.ts index 44f3a5e7..a4c891c3 100644 --- a/packages/universal/api-schemas/src/insights/event/InsightsEvent.ts +++ b/packages/universal/api-schemas/src/insights/event/InsightsEvent.ts @@ -2,17 +2,23 @@ import * as z from 'zod/mini' import { ViewEvent } from '../../experience/event' import { ClickEvent } from './ClickEvent' import { HoverEvent } from './HoverEvent' +import { NodeViewEvent } from './NodeViewEvent' /** * Zod schema describing an Insights event. * * @remarks * Insights events include {@link ViewEvent}, - * {@link ClickEvent}, and {@link HoverEvent}. + * {@link ClickEvent}, {@link HoverEvent}, and {@link NodeViewEvent}. * * @public */ -export const InsightsEvent = z.discriminatedUnion('type', [ViewEvent, ClickEvent, HoverEvent]) +export const InsightsEvent = z.discriminatedUnion('type', [ + ViewEvent, + ClickEvent, + HoverEvent, + NodeViewEvent, +]) /** * TypeScript type inferred from {@link InsightsEvent}. diff --git a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts new file mode 100644 index 00000000..cdb25e59 --- /dev/null +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from '@rstest/core' +import { InsightsEvent } from './InsightsEvent' +import { NodeViewEvent } from './NodeViewEvent' + +const BASE_UNIVERSAL = { + channel: 'web' as const, + context: { + campaign: {}, + gdpr: { isConsentGiven: true }, + library: { name: 'test', version: '0.0.0' }, + locale: 'en-US', + }, + messageId: 'msg-1', + originalTimestamp: '2024-01-01T00:00:00.000Z', + sentAt: '2024-01-01T00:00:00.000Z', + timestamp: '2024-01-01T00:00:00.000Z', +} + +const VALID_NODE_VIEW = { + ...BASE_UNIVERSAL, + type: 'exo_view' as const, + entityId: 'exp-sys-id', + entityKind: 'Experience' as const, + variant: 'variant-a', + optimizationId: 'opt-id', + viewId: 'view-uuid', + viewDurationMs: 1500, +} + +describe('NodeViewEvent schema', () => { + it('accepts a valid payload', () => { + const result = NodeViewEvent.safeParse(VALID_NODE_VIEW) + + expect(result.success).toBe(true) + }) + + it('accepts all valid entityKind values', () => { + const kinds = ['Experience', 'Fragment', 'InlineFragment', 'InlineComponent'] as const + + for (const entityKind of kinds) { + const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind }) + + expect(result.success, `entityKind=${entityKind}`).toBe(true) + } + }) + + it('rejects an unknown entityKind', () => { + const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind: 'Unknown' }) + + expect(result.success).toBe(false) + }) + + it('rejects a missing required field', () => { + const { entityId: _removed, ...withoutEntityId } = VALID_NODE_VIEW + const result = NodeViewEvent.safeParse(withoutEntityId) + + expect(result.success).toBe(false) + }) + + it('rejects a non-number viewDurationMs', () => { + const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, viewDurationMs: 'long' }) + + expect(result.success).toBe(false) + }) +}) + +describe('InsightsEvent discriminated union', () => { + it('discriminates exo_view correctly', () => { + const result = InsightsEvent.safeParse(VALID_NODE_VIEW) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe('exo_view') + } + }) +}) diff --git a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts new file mode 100644 index 00000000..98af5bde --- /dev/null +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts @@ -0,0 +1,123 @@ +import * as z from 'zod/mini' +import { UniversalEventProperties } from '../../experience/event/UniversalEventProperties' + +const EntityKind = z.union([ + z.literal('Experience'), + z.literal('Fragment'), + z.literal('InlineFragment'), + z.literal('InlineComponent'), +]) + +/** + * A resolved ancestor personalization layer carried on `exo_*` events. + * + * @public + */ +export const ExoNodeLayer = z.object({ + entityKind: EntityKind, + entityId: z.string(), + variant: z.optional(z.string()), + optimizationId: z.optional(z.string()), +}) + +/** + * TypeScript type inferred from {@link ExoNodeLayer}. + * + * @public + */ +export type ExoNodeLayer = z.infer + +/** + * Zod schema describing an `exo_view` event used for XDA graph node + * viewport tracking. + * + * @remarks + * These events track the exposure of rendered XDA nodes in the browser + * viewport. They are self-contained: all metadata required by the ingestor + * is embedded in the payload and no server-side lookup is needed. + * + * Unlike {@link ViewEvent}, which is entry-centric, `NodeViewEvent` is + * graph-node-centric and carries structural metadata resolved from the XDA + * `extensions.sourceMap`. + * + * @public + */ +export const NodeViewEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator identifying this as an XDA node view event. + */ + type: z.literal('exo_view'), + + /** + * `sys.id` of the Experience or Fragment that owns this node. + */ + entityId: z.string(), + + /** + * Structural kind of the owning entity. + */ + entityKind: EntityKind, + + /** + * Variant identifier selected for this node. + * + * @remarks + * Resolved from `extensions.sourceMap.variants[].id`. + */ + variant: z.string(), + + /** + * Ninetailed experience (optimization) ID associated with this node. + */ + optimizationId: z.string(), + + /** + * UUID identifying a single active view session for this node. + * + * @remarks + * Multiple events emitted for the same active view share this identifier. + */ + viewId: z.string(), + + /** + * Monotonically increasing visible duration for the active view, in + * milliseconds. + * + * @remarks + * Updated and re-emitted while the same view remains active. + */ + viewDurationMs: z.number(), + + /** + * Composite entity-kind identifier, when the owning entity has a subtype. + */ + entityKindId: z.optional(z.string()), + + /** + * Contentful `sys.id` values of content entries associated with this node. + */ + entryIds: z.optional(z.array(z.string())), + + /** + * Ancestor personalization layers resolved from the XDA sourceMap, ordered + * leaf-to-root. + */ + layers: z.optional(z.array(ExoNodeLayer)), + + /** + * Arbitrary key-value parameters captured at view time. + */ + parameters: z.optional(z.record(z.string(), z.unknown())), + + /** + * `sys.id` of the parent Experience when this node is nested inside one. + */ + parentExperienceId: z.optional(z.string()), +}) + +/** + * TypeScript type inferred from {@link NodeViewEvent}. + * + * @public + */ +export type NodeViewEvent = z.infer diff --git a/packages/universal/api-schemas/src/insights/event/index.ts b/packages/universal/api-schemas/src/insights/event/index.ts index f3a2a2ac..adf37234 100644 --- a/packages/universal/api-schemas/src/insights/event/index.ts +++ b/packages/universal/api-schemas/src/insights/event/index.ts @@ -2,3 +2,4 @@ export * from './BatchInsightsEvent' export * from './ClickEvent' export * from './HoverEvent' export * from './InsightsEvent' +export * from './NodeViewEvent' From 0a52df036cb8f5344ee9fc91464034b727653894 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:00:43 +0200 Subject: [PATCH 02/16] feat(core-sdk): add trackNodeView and node-view event builder Add NodeViewBuilderArgs Zod schema extending UniversalEventBuilderArgs with the required exo_view fields and the optional brief fields (entityKindId, entryIds, layers, parameters, parentExperienceId). Add EventBuilder.buildNodeView() which assembles the exo_view payload via buildUniversalEventProperties. Add CoreStatefulEventEmitter.trackNodeView() routing to sendInsightsEvent, with a trackNodeView -> 'exo_view' entry in CONSENT_EVENT_TYPE_MAP. --- .../core-sdk/src/CoreStatefulEventEmitter.ts | 27 ++++++ .../core-sdk/src/CoreStatefulNodeView.test.ts | 77 +++++++++++++++++ .../core-sdk/src/events/EventBuilder.test.ts | 40 +++++++++ .../core-sdk/src/events/EventBuilder.ts | 82 +++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index 6d0c497c..8fa7aac9 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -21,6 +21,7 @@ import type { FlagViewBuilderArgs, HoverBuilderArgs, IdentifyBuilderArgs, + NodeViewBuilderArgs, PageViewBuilderArgs, ScreenViewBuilderArgs, TrackBuilderArgs, @@ -47,6 +48,7 @@ const CONSENT_EVENT_TYPE_MAP: Readonly>> = { trackFlagView: 'component', trackClick: 'component_click', trackHover: 'component_hover', + trackNodeView: 'exo_view', } /** @@ -287,6 +289,31 @@ abstract class CoreStatefulEventEmitter ) } + /** + * Track an XDA graph node view through Insights. + * + * @param payload - Node view builder arguments. + * @returns A promise that resolves when processing completes. + * @example + * ```ts + * await core.trackNodeView({ + * entityId: 'experience-sys-id', + * entityKind: 'Experience', + * variant: 'variant-a', + * optimizationId: 'optimization-id', + * viewId: crypto.randomUUID(), + * viewDurationMs: 1_000, + * }) + * ``` + */ + async trackNodeView(payload: NodeViewBuilderArgs): Promise { + await this.sendInsightsEvent( + 'trackNodeView', + [payload], + this.eventBuilder.buildNodeView(payload), + ) + } + hasConsent(name: string): boolean { const { [name]: mappedEventType } = CONSENT_EVENT_TYPE_MAP const isAllowed = diff --git a/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts new file mode 100644 index 00000000..01f4792c --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts @@ -0,0 +1,77 @@ +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import type { NodeViewBuilderArgs } from './events' +import { batch, signals } from './signals' +import { profile as profileFixture } from './test/fixtures/profile' + +const config: CoreStatefulConfig = { + clientId: 'key_123', + environment: 'main', +} + +describe('CoreStateful.trackNodeView', () => { + const createdCores: CoreStateful[] = [] + + const createCore = (overrides: Partial = {}): CoreStateful => { + const core = new CoreStateful({ ...config, ...overrides }) + createdCores.push(core) + return core + } + + beforeEach(() => { + batch(() => { + signals.blockedEvent.value = undefined + signals.consent.value = undefined + signals.event.value = undefined + signals.online.value = true + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + }) + }) + + afterEach(() => { + while (createdCores.length > 0) { + createdCores.pop()?.destroy() + } + rs.restoreAllMocks() + }) + + const nodeViewPayload: NodeViewBuilderArgs = { + entityId: 'exp-sys-id', + entityKind: 'Experience', + variant: 'variant-a', + optimizationId: 'opt-id', + viewId: 'view-uuid', + viewDurationMs: 1500, + } + + it('routes trackNodeView to insights queue when consent is given', async () => { + const core = createCore({ + defaults: { consent: true, profile: profileFixture }, + }) + const sendSpy = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackNodeView(nodeViewPayload) + await core.flush() + + expect(sendSpy).toHaveBeenCalledTimes(1) + const firstCall = sendSpy.mock.calls[0] + expect(firstCall).toBeDefined() + + const batches = firstCall?.[0] ?? [] + const events = batches.flatMap((b) => b.events) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('exo_view') + }) + + it('blocks trackNodeView when consent is not given', async () => { + const onEventBlocked = rs.fn() + const core = createCore({ onEventBlocked }) + + await core.trackNodeView(nodeViewPayload) + + expect(onEventBlocked).toHaveBeenCalledTimes(1) + expect(onEventBlocked).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'consent', method: 'trackNodeView' }), + ) + }) +}) diff --git a/packages/universal/core-sdk/src/events/EventBuilder.test.ts b/packages/universal/core-sdk/src/events/EventBuilder.test.ts index 91e92ccb..e60b2df1 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.test.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.test.ts @@ -6,6 +6,46 @@ const builder = new EventBuilder({ library: { name: '@contentful/optimization-ios', version: '0.0.1' }, }) +describe('EventBuilder.buildNodeView', () => { + it('builds a valid exo_view event', () => { + const event = builder.buildNodeView({ + entityId: 'exp-sys-id', + entityKind: 'Experience', + variant: 'variant-a', + optimizationId: 'opt-id', + viewId: 'view-uuid', + viewDurationMs: 1500, + }) + + expect(event.type).toBe('exo_view') + expect(event.entityId).toBe('exp-sys-id') + expect(event.entityKind).toBe('Experience') + expect(event.variant).toBe('variant-a') + expect(event.optimizationId).toBe('opt-id') + expect(event.viewId).toBe('view-uuid') + expect(event.viewDurationMs).toBe(1500) + expect(event.channel).toBe('mobile') + }) + + it('stamps universal context fields', () => { + const event = builder.buildNodeView({ + entityId: 'exp-id', + entityKind: 'Fragment', + variant: 'default', + optimizationId: 'opt-id', + viewId: 'view-uuid', + viewDurationMs: 0, + }) + + expect(event.messageId).toBeTruthy() + expect(event.timestamp).toBeTruthy() + expect(event.context.library).toEqual({ + name: '@contentful/optimization-ios', + version: '0.0.1', + }) + }) +}) + describe('EventBuilder.buildScreenView', () => { it('builds a valid screen event without an explicit screen context', () => { const event = builder.buildScreenView({ name: 'Home', properties: {} }) diff --git a/packages/universal/core-sdk/src/events/EventBuilder.ts b/packages/universal/core-sdk/src/events/EventBuilder.ts index 4d23184b..67a5b685 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.ts @@ -3,10 +3,12 @@ import { Campaign, type Channel, type ClickEvent, + type ExoNodeLayer, GeoLocation, type HoverEvent, type IdentifyEvent, type Library, + type NodeViewEvent, Page, PageEventContext, type PageViewEvent, @@ -240,6 +242,32 @@ export const TrackBuilderArgs = z.extend(UniversalEventBuilderArgs, { */ export type TrackBuilderArgs = z.infer +export const NodeViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { + entityId: z.string(), + entityKind: z.union([ + z.literal('Experience'), + z.literal('Fragment'), + z.literal('InlineFragment'), + z.literal('InlineComponent'), + ]), + variant: z.string(), + optimizationId: z.string(), + viewId: z.string(), + viewDurationMs: z.number(), + entityKindId: z.optional(z.string()), + entryIds: z.optional(z.array(z.string())), + layers: z.optional(z.array(z.custom())), + parameters: z.optional(z.record(z.string(), z.unknown())), + parentExperienceId: z.optional(z.string()), +}) + +/** + * Arguments for constructing `exo_view` events. + * + * @public + */ +export type NodeViewBuilderArgs = z.infer + /** * Default page properties used when no explicit page information is available. * @@ -687,6 +715,60 @@ class EventBuilder { properties, } } + + /** + * Builds an `exo_view` event payload for XDA graph node viewport + * tracking. + * + * @param args - {@link NodeViewBuilderArgs} arguments describing the node view. + * @returns A {@link NodeViewEvent} payload. + * + * @example + * ```ts + * const event = builder.buildNodeView({ + * entityId: 'experience-sys-id', + * entityKind: 'Experience', + * variant: 'variant-a', + * optimizationId: 'optimization-id', + * viewId: crypto.randomUUID(), + * viewDurationMs: 1_000, + * }) + * ``` + * + * @public + */ + buildNodeView(args: NodeViewBuilderArgs): NodeViewEvent { + const { + entityId, + entityKind, + variant, + optimizationId, + viewId, + viewDurationMs, + entityKindId, + entryIds, + layers, + parameters, + parentExperienceId, + ...universal + } = parseWithFriendlyError(NodeViewBuilderArgs, args) + + return { + ...this.buildUniversalEventProperties(universal), + type: 'exo_view', + entityId, + entityKind, + variant, + optimizationId, + viewId, + viewDurationMs, + entityKindId, + entryIds, + layers, + parameters, + parentExperienceId, + } + } } export default EventBuilder From 2a73cb1cfebf182ac7a5eb67f94a791b94edbcdb Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:01:06 +0200 Subject: [PATCH 03/16] feat(web-sdk): add automatic exo_view runtime and payload resolution Add NODE_VIEW_SELECTOR ('[data-ctfl-node-id]') constant. Add resolveNodeViewPayload() which resolves entityId, entityKind, optimizationId and variant from an XDA extensions.sourceMap for a given rendered node ID, walking the node's own layer chain from the scope position upward. Add createNodeViewDetector() backed by ElementViewObserver that reads data-ctfl-* dataset attributes and calls core.trackNodeView(). Add NodeViewRuntime which uses ElementExistenceObserver to auto-discover [data-ctfl-node-id] elements and delegates to createNodeViewDetector. Wire NodeViewRuntime into ContentfulOptimization: start/stop on consent change, stop on reset, destroy on destroy. Extend CtflDataset with ctflNodeId, ctflEntityId, ctflEntityKind and ctflVariant. Re-export resolveNodeViewPayload from the package index. --- .../web/web-sdk/src/ContentfulOptimization.ts | 19 +++ packages/web/web-sdk/src/constants.ts | 7 ++ .../src/entry-tracking/NodeViewRuntime.ts | 81 +++++++++++++ .../view/createNodeViewDetector.test.ts | 63 ++++++++++ .../events/view/createNodeViewDetector.ts | 102 ++++++++++++++++ .../resolveNodeViewPayload.test.ts | 112 ++++++++++++++++++ .../entry-tracking/resolveNodeViewPayload.ts | 79 ++++++++++++ .../entry-tracking/resolveTrackingPayload.ts | 11 ++ packages/web/web-sdk/src/index.ts | 1 + 9 files changed, 475 insertions(+) create mode 100644 packages/web/web-sdk/src/entry-tracking/NodeViewRuntime.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.test.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts create mode 100644 packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 42cf2de9..2c2a9a26 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -26,6 +26,7 @@ import { } from './constants' import type { AutoTrackEntryInteractionOptions, EntryInteractionApi } from './entry-tracking' import { EntryInteractionRuntime } from './entry-tracking/EntryInteractionRuntime' +import { NodeViewRuntime } from './entry-tracking/NodeViewRuntime' import { beaconHandler, createOnlineChangeListener, @@ -204,6 +205,13 @@ class ContentfulOptimization extends CoreStateful { * @internal */ private readonly entryInteractionRuntime: EntryInteractionRuntime + + /** + * Runtime for automatic `exo_view` viewport tracking. + * + * @internal + */ + private readonly nodeViewRuntime: NodeViewRuntime /** * Namespaced tracking controls for automatic and per-element entry interactions. * @@ -269,6 +277,8 @@ class ContentfulOptimization extends CoreStateful { this.entryInteractionRuntime = entryInteractionRuntime this.tracking = tracking + this.nodeViewRuntime = new NodeViewRuntime(this) + this.cookieAttributes = { domain: mergedConfig.cookie?.domain, expires: mergedConfig.cookie?.expires ?? EXPIRATION_DAYS_DEFAULT, @@ -296,6 +306,13 @@ class ContentfulOptimization extends CoreStateful { } = signals this.entryInteractionRuntime.syncAutoTrackedEntryInteractions(!!value) + + if (value) { + this.nodeViewRuntime.start() + } else { + this.nodeViewRuntime.stop() + } + LocalStore.consent = value }) @@ -380,6 +397,7 @@ class ContentfulOptimization extends CoreStateful { */ reset(): void { this.entryInteractionRuntime.reset() + this.nodeViewRuntime.stop() removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) LocalStore.reset() super.reset() @@ -394,6 +412,7 @@ class ContentfulOptimization extends CoreStateful { */ destroy(): void { this.entryInteractionRuntime.destroy() + this.nodeViewRuntime.destroy() this.cleanupOnlineListener() this.cleanupVisibilityListener() diff --git a/packages/web/web-sdk/src/constants.ts b/packages/web/web-sdk/src/constants.ts index 7198c158..a3d74aba 100644 --- a/packages/web/web-sdk/src/constants.ts +++ b/packages/web/web-sdk/src/constants.ts @@ -49,6 +49,13 @@ export { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-core/constants' */ export const ENTRY_SELECTOR = '[data-ctfl-entry-id]' +/** + * Selector used to locate tracked XDA node elements in the DOM. + * + * @public + */ +export const NODE_VIEW_SELECTOR = '[data-ctfl-node-id]' + /** * Flag indicating whether the current environment can safely add DOM * event listeners. diff --git a/packages/web/web-sdk/src/entry-tracking/NodeViewRuntime.ts b/packages/web/web-sdk/src/entry-tracking/NodeViewRuntime.ts new file mode 100644 index 00000000..79fd75ed --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/NodeViewRuntime.ts @@ -0,0 +1,81 @@ +import { NODE_VIEW_SELECTOR } from '../constants' +import { + createNodeViewDetector, + type NodeViewDetector, + type NodeViewTrackingCore, +} from './events/view/createNodeViewDetector' +import type { ElementViewObserverOptions } from './events/view/element-view-observer-support' +import ElementExistenceObserver from './registry/ElementExistenceObserver' + +/** + * Runtime that manages automatic `exo_view` tracking for all DOM elements + * carrying `data-ctfl-node-id` attributes. + * + * @remarks + * Attaches a `MutationObserver` (via {@link ElementExistenceObserver}) to detect + * when node-view elements enter or leave the DOM, and delegates viewport dwell + * tracking to a {@link NodeViewDetector}. + * + * Call {@link NodeViewRuntime.start} to activate and {@link NodeViewRuntime.destroy} + * to release all resources. + * + * @internal + */ +export class NodeViewRuntime { + private readonly core: NodeViewTrackingCore + private readonly observerOptions: ElementViewObserverOptions | undefined + private detector: NodeViewDetector | undefined + private existenceObserver: ElementExistenceObserver | undefined + private cleanupExistence: (() => void) | undefined + + public constructor(core: NodeViewTrackingCore, options?: ElementViewObserverOptions) { + this.core = core + this.observerOptions = options + } + + public start(): void { + if (this.detector) return + + this.detector = createNodeViewDetector(this.core, this.observerOptions) + + this.existenceObserver = new ElementExistenceObserver() + + this.cleanupExistence = this.existenceObserver.subscribe({ + onAdded: (elements): void => { + for (const element of elements) { + if (element.matches(NODE_VIEW_SELECTOR)) { + this.detector?.observe(element) + } + } + }, + onRemoved: (elements): void => { + for (const element of elements) { + if (element.matches(NODE_VIEW_SELECTOR)) { + this.detector?.unobserve(element) + } + } + }, + }) + + if (typeof document !== 'undefined') { + document.querySelectorAll(NODE_VIEW_SELECTOR).forEach((element) => { + this.detector?.observe(element) + }) + } + } + + public stop(): void { + this.cleanupExistence?.() + this.cleanupExistence = undefined + + this.existenceObserver?.disconnect() + this.existenceObserver = undefined + + this.detector?.disconnect() + this.detector = undefined + } + + public destroy(): void { + this.stop() + } +} diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.test.ts new file mode 100644 index 00000000..bba68feb --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from '@rstest/core' +import { createNodeViewDetector, type NodeViewTrackingCore } from './createNodeViewDetector' + +function makeElement(dataset: Record = {}): HTMLElement { + const el = document.createElement('div') + for (const [key, value] of Object.entries(dataset)) { + el.dataset[key] = value + } + return el +} + +function makeCore(): { trackNodeView: ReturnType; core: NodeViewTrackingCore } { + const trackNodeView = rs.fn().mockResolvedValue(undefined) + return { trackNodeView, core: { trackNodeView } } +} + +describe('createNodeViewDetector', () => { + it('returns observe/unobserve/disconnect', () => { + const { core } = makeCore() + const detector = createNodeViewDetector(core) + + expect(typeof detector.observe).toBe('function') + expect(typeof detector.unobserve).toBe('function') + expect(typeof detector.disconnect).toBe('function') + }) + + it('does not throw when disconnected before any observation', () => { + const { core } = makeCore() + const detector = createNodeViewDetector(core) + + expect(() => { + detector.disconnect() + }).not.toThrow() + }) + + it('does not call trackNodeView when required dataset attributes are missing', () => { + const { core, trackNodeView } = makeCore() + const detector = createNodeViewDetector(core, { dwellTimeMs: 0 }) + const el = makeElement({ ctflNodeId: 'node-1' }) + + detector.observe(el) + detector.unobserve(el) + + expect(trackNodeView).not.toHaveBeenCalled() + }) + + it('does not call trackNodeView for unknown entityKind in dataset', () => { + const { core, trackNodeView } = makeCore() + const detector = createNodeViewDetector(core, { dwellTimeMs: 0 }) + const el = makeElement({ + ctflNodeId: 'node-1', + ctflEntityId: 'exp-id', + ctflEntityKind: 'Unknown', + ctflOptimizationId: 'opt-id', + ctflVariant: 'variant-a', + }) + + detector.observe(el) + detector.unobserve(el) + + expect(trackNodeView).not.toHaveBeenCalled() + }) +}) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts new file mode 100644 index 00000000..b0b1bcd1 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -0,0 +1,102 @@ +import type { NodeViewBuilderArgs } from '@contentful/optimization-core' +import { isHtmlOrSvgElement } from '../createTimedEntryDetector' +import type { + ElementViewCallbackInfo, + ElementViewObserverOptions, +} from './element-view-observer-support' +import ElementViewObserver from './ElementViewObserver' + +/** + * Minimal core interface required to track node view events. + * + * @internal + */ +export interface NodeViewTrackingCore { + trackNodeView: (payload: NodeViewBuilderArgs) => Promise +} + +/** + * A running node-view detector returned by {@link createNodeViewDetector}. + * + * @internal + */ +export interface NodeViewDetector { + /** Begin observing an element for viewport dwell. */ + observe: (element: Element) => void + /** Stop observing an element. */ + unobserve: (element: Element) => void + /** Disconnect and release all resources. */ + disconnect: () => void +} + +function isKnownEntityKind(kind: string): kind is NodeViewBuilderArgs['entityKind'] { + return ( + kind === 'Experience' || + kind === 'Fragment' || + kind === 'InlineFragment' || + kind === 'InlineComponent' + ) +} + +function resolveNodeViewArgs( + element: Element, + info: ElementViewCallbackInfo, +): NodeViewBuilderArgs | undefined { + if (!isHtmlOrSvgElement(element)) return undefined + + const { + dataset: { ctflNodeId, ctflEntityId, ctflEntityKind, ctflOptimizationId, ctflVariant }, + } = element + + if (!ctflNodeId || !ctflEntityId || !ctflEntityKind || !ctflOptimizationId || !ctflVariant) + return undefined + + if (!isKnownEntityKind(ctflEntityKind)) return undefined + + return { + entityId: ctflEntityId, + entityKind: ctflEntityKind, + optimizationId: ctflOptimizationId, + variant: ctflVariant, + viewId: info.viewId, + viewDurationMs: Math.max(0, Math.round(info.totalVisibleMs)), + } +} + +/** + * Create an `ElementViewObserver`-backed detector that fires `trackNodeView` + * once an element with node-view dataset attributes has dwelled in the + * viewport. + * + * @param core - Object exposing {@link NodeViewTrackingCore.trackNodeView}. + * @param options - Optional `ElementViewObserver` configuration (dwell time, + * visible ratio, etc.). + * @returns A {@link NodeViewDetector} that manages element observation. + * + * @internal + */ +export function createNodeViewDetector( + core: NodeViewTrackingCore, + options?: ElementViewObserverOptions, +): NodeViewDetector { + const callback = async (element: Element, info: ElementViewCallbackInfo): Promise => { + const args = resolveNodeViewArgs(element, info) + if (args !== undefined) { + await core.trackNodeView(args) + } + } + + const observer = new ElementViewObserver(callback, options) + + return { + observe: (element): void => { + observer.observe(element) + }, + unobserve: (element): void => { + observer.unobserve(element) + }, + disconnect: (): void => { + observer.disconnect() + }, + } +} diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts new file mode 100644 index 00000000..f9f662ab --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts @@ -0,0 +1,112 @@ +import type { SourceMap } from '@contentful/optimization-core/api-schemas' +import { describe, expect, it } from '@rstest/core' +import { resolveNodeViewPayload } from './resolveNodeViewPayload' + +const SOURCE_MAP: SourceMap = { + variants: [ + { type: 'personalization', id: 'default' }, + { type: 'personalization', id: 'variant-a' }, + ], + layers: [ + { kind: 'Experience', id: 'exp-id', variants: [1] }, + { kind: 'Fragment', id: 'frag-id', variants: [0] }, + ], + nodes: { + 'node-exp': { layers: [0], scope: 0 }, + 'node-frag': { layers: [1, 0], scope: 1 }, + 'node-no-scope-variants': { layers: [1], scope: 1 }, + }, +} + +describe('resolveNodeViewPayload', () => { + it('resolves metadata for a node scoped to an Experience layer', () => { + const result = resolveNodeViewPayload('node-exp', SOURCE_MAP) + + expect(result).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'exp-id', + variant: 'variant-a', + }) + }) + + it('resolves metadata for a node scoped to a Fragment layer', () => { + const result = resolveNodeViewPayload('node-frag', SOURCE_MAP) + + expect(result).toEqual({ + entityId: 'frag-id', + entityKind: 'Fragment', + optimizationId: 'frag-id', + variant: 'default', + }) + }) + + it('returns undefined when nodeId is not in sourceMap', () => { + const result = resolveNodeViewPayload('nonexistent', SOURCE_MAP) + + expect(result).toBeUndefined() + }) + + it('returns undefined when scope layer has no variants', () => { + const sourceMapNoVariants: SourceMap = { + variants: [], + layers: [{ kind: 'Fragment', id: 'frag-id' }], + nodes: { 'node-1': { layers: [0], scope: 0 } }, + } + + const result = resolveNodeViewPayload('node-1', sourceMapNoVariants) + + expect(result).toBeUndefined() + }) + + it('skips scope layer without variants and falls through to next layer', () => { + const mixedSourceMap: SourceMap = { + variants: [{ type: 'personalization', id: 'variant-b' }], + layers: [ + { kind: 'Fragment', id: 'frag-no-variants' }, + { kind: 'Experience', id: 'exp-id', variants: [0] }, + ], + nodes: { + 'node-1': { layers: [0, 1], scope: 0 }, + }, + } + + const result = resolveNodeViewPayload('node-1', mixedSourceMap) + + expect(result).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'exp-id', + variant: 'variant-b', + }) + }) + + it('does not use unrelated global layers outside the node layer chain', () => { + const sourceMapWithUnrelatedLayer: SourceMap = { + variants: [{ type: 'personalization', id: 'variant-c' }], + layers: [ + { kind: 'Fragment', id: 'frag-no-variants' }, + { kind: 'Experience', id: 'unrelated-exp', variants: [0] }, + ], + nodes: { + 'node-1': { layers: [0], scope: 0 }, + }, + } + + const result = resolveNodeViewPayload('node-1', sourceMapWithUnrelatedLayer) + + expect(result).toBeUndefined() + }) + + it('returns undefined when layer kind is not a known entity kind', () => { + const unknownKindSourceMap: SourceMap = { + variants: [{ type: 'personalization', id: 'v' }], + layers: [{ kind: 'Unknown', id: 'unknown-id', variants: [0] }], + nodes: { 'node-1': { layers: [0], scope: 0 } }, + } + + const result = resolveNodeViewPayload('node-1', unknownKindSourceMap) + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts new file mode 100644 index 00000000..a442f990 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts @@ -0,0 +1,79 @@ +import type { NodeViewBuilderArgs } from '@contentful/optimization-core' +import type { SourceMap } from '@contentful/optimization-core/api-schemas' + +/** + * Subset of {@link NodeViewBuilderArgs} that can be resolved from a sourceMap + * node entry, excluding timing fields that are supplied by the viewport + * observer at fire time. + * + * @internal + */ +export type ResolvedNodeMetadata = Pick< + NodeViewBuilderArgs, + 'entityId' | 'entityKind' | 'optimizationId' | 'variant' +> + +const KNOWN_ENTITY_KINDS = new Set(['Experience', 'Fragment', 'InlineFragment', 'InlineComponent']) + +function isKnownEntityKind(kind: string): kind is ResolvedNodeMetadata['entityKind'] { + return KNOWN_ENTITY_KINDS.has(kind) +} + +function resolveLayerAtIndex( + layerIndex: number | undefined, + layers: SourceMap['layers'], + variants: SourceMap['variants'], +): ResolvedNodeMetadata | undefined { + if (layerIndex === undefined) return undefined + + const { [layerIndex]: layer } = layers + if (!layer?.variants?.length) return undefined + + const { [layer.variants[0] ?? -1]: variant } = variants + if (variant === undefined) return undefined + + const { kind, id } = layer + if (!isKnownEntityKind(kind)) return undefined + + return { entityId: id, entityKind: kind, optimizationId: id, variant: variant.id } +} + +/** + * Resolve node view metadata from an XDA `extensions.sourceMap` for a given + * rendered node ID. + * + * @remarks + * The function walks the node's `layers[]` chain (leaf-to-root), starting at + * the position of the node's `scope` layer index (nearest ancestor Fragment or + * Experience), and returns metadata for the first layer that has a `variants` + * reference. If no such layer is found the node cannot be attributed and the + * function returns `undefined`. + * + * @param nodeId - The rendered node ID to resolve, matching a key in + * `sourceMap.nodes`. + * @param sourceMap - The `extensions.sourceMap` object from the XDA response. + * @returns Resolved node metadata or `undefined` when the node is absent or + * has no attributable layer. + * + * @internal + */ +export function resolveNodeViewPayload( + nodeId: string, + sourceMap: SourceMap, +): ResolvedNodeMetadata | undefined { + const { nodes, layers, variants } = sourceMap + const { [nodeId]: node } = nodes + if (node === undefined) return undefined + + const { layers: nodeLayers, scope } = node + const scopePosition = nodeLayers.indexOf(scope) + if (scopePosition < 0) return undefined + + for (let i = scopePosition; i < nodeLayers.length; i++) { + const { [i]: layerIndex } = nodeLayers + const resolved = resolveLayerAtIndex(layerIndex, layers, variants) + if (resolved !== undefined) return resolved + } + + return undefined +} diff --git a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts index 3d554274..5909fa27 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts @@ -24,6 +24,17 @@ export type CtflDataset = DOMStringMap & { ctflViewDurationUpdateIntervalMs?: string /** Optional per-element hover-duration update interval override in milliseconds. */ ctflHoverDurationUpdateIntervalMs?: string + /** + * Rendered node ID from the XDA `extensions.sourceMap.nodes` map. + * Presence of this attribute routes the element to `exo_view` tracking. + */ + ctflNodeId?: string + /** Resolved Experience or Fragment `sys.id` for `exo_view` events. */ + ctflEntityId?: string + /** Resolved entity kind for `exo_view` events. */ + ctflEntityKind?: string + /** Resolved variant identifier for `exo_view` events. */ + ctflVariant?: string } /** diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index 9ef9c4f9..a8a61cc2 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -40,6 +40,7 @@ export type { EntryViewInteractionElementOptions, EntryViewInteractionStartOptions, } from './entry-tracking' +export * from './entry-tracking/resolveNodeViewPayload' export * from './handlers/beaconHandler' export * from './storage/LocalStore' From a77d52872bd3d7b359bb79f547e8e19a2a894ae1 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:17:59 +0200 Subject: [PATCH 04/16] feat(api-schemas): add anonymousId to NodeViewEvent The brief marks anonymousId as a required event field. Add it to the NodeViewEvent schema as a required string ahead of the type discriminator. --- .../api-schemas/src/insights/event/NodeViewEvent.test.ts | 1 + .../api-schemas/src/insights/event/NodeViewEvent.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts index cdb25e59..b9686687 100644 --- a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts @@ -18,6 +18,7 @@ const BASE_UNIVERSAL = { const VALID_NODE_VIEW = { ...BASE_UNIVERSAL, + anonymousId: 'anon-id', type: 'exo_view' as const, entityId: 'exp-sys-id', entityKind: 'Experience' as const, diff --git a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts index 98af5bde..484f808a 100644 --- a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts @@ -43,6 +43,11 @@ export type ExoNodeLayer = z.infer * @public */ export const NodeViewEvent = z.extend(UniversalEventProperties, { + /** + * Stable anonymous user identifier for this event. + */ + anonymousId: z.string(), + /** * Discriminator identifying this as an XDA node view event. */ From aa9c02aee05565f53b9d25f3e32aa45e37b45fa4 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:22:37 +0200 Subject: [PATCH 05/16] feat(core-sdk): add NodeViewTrackingArgs and derive anonymousId in trackNodeView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce NodeViewTrackingArgs as the public-facing type for runtime callers — it extends NodeViewBuilderArgs but makes anonymousId optional. trackNodeView() now accepts NodeViewTrackingArgs and derives anonymousId from the active profile signal when the caller omits it, falling back to an empty string. buildNodeView() threads anonymousId onto the event payload. --- .../core-sdk/src/CoreStatefulEventEmitter.ts | 8 ++++++-- .../core-sdk/src/CoreStatefulNodeView.test.ts | 8 +++++--- .../core-sdk/src/events/EventBuilder.test.ts | 3 +++ .../core-sdk/src/events/EventBuilder.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index 8fa7aac9..8ad1dd52 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -22,6 +22,7 @@ import type { HoverBuilderArgs, IdentifyBuilderArgs, NodeViewBuilderArgs, + NodeViewTrackingArgs, PageViewBuilderArgs, ScreenViewBuilderArgs, TrackBuilderArgs, @@ -306,11 +307,14 @@ abstract class CoreStatefulEventEmitter * }) * ``` */ - async trackNodeView(payload: NodeViewBuilderArgs): Promise { + async trackNodeView(payload: NodeViewTrackingArgs): Promise { + const anonymousId = payload.anonymousId ?? profileSignal.value?.id ?? '' + const builderArgs: NodeViewBuilderArgs = { ...payload, anonymousId } + await this.sendInsightsEvent( 'trackNodeView', [payload], - this.eventBuilder.buildNodeView(payload), + this.eventBuilder.buildNodeView(builderArgs), ) } diff --git a/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts index 01f4792c..f366502d 100644 --- a/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts +++ b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts @@ -1,5 +1,5 @@ import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' -import type { NodeViewBuilderArgs } from './events' +import type { NodeViewTrackingArgs } from './events' import { batch, signals } from './signals' import { profile as profileFixture } from './test/fixtures/profile' @@ -35,7 +35,7 @@ describe('CoreStateful.trackNodeView', () => { rs.restoreAllMocks() }) - const nodeViewPayload: NodeViewBuilderArgs = { + const nodeViewPayload: NodeViewTrackingArgs = { entityId: 'exp-sys-id', entityKind: 'Experience', variant: 'variant-a', @@ -60,7 +60,9 @@ describe('CoreStateful.trackNodeView', () => { const batches = firstCall?.[0] ?? [] const events = batches.flatMap((b) => b.events) expect(events).toHaveLength(1) - expect(events[0]?.type).toBe('exo_view') + const nodeViewEvent = events.find((e) => e.type === 'exo_view') + expect(nodeViewEvent).toBeDefined() + expect(nodeViewEvent?.anonymousId).toBe(profileFixture.id) }) it('blocks trackNodeView when consent is not given', async () => { diff --git a/packages/universal/core-sdk/src/events/EventBuilder.test.ts b/packages/universal/core-sdk/src/events/EventBuilder.test.ts index e60b2df1..6a28bf63 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.test.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.test.ts @@ -9,6 +9,7 @@ const builder = new EventBuilder({ describe('EventBuilder.buildNodeView', () => { it('builds a valid exo_view event', () => { const event = builder.buildNodeView({ + anonymousId: 'anon-id', entityId: 'exp-sys-id', entityKind: 'Experience', variant: 'variant-a', @@ -18,6 +19,7 @@ describe('EventBuilder.buildNodeView', () => { }) expect(event.type).toBe('exo_view') + expect(event.anonymousId).toBe('anon-id') expect(event.entityId).toBe('exp-sys-id') expect(event.entityKind).toBe('Experience') expect(event.variant).toBe('variant-a') @@ -29,6 +31,7 @@ describe('EventBuilder.buildNodeView', () => { it('stamps universal context fields', () => { const event = builder.buildNodeView({ + anonymousId: 'anon-id', entityId: 'exp-id', entityKind: 'Fragment', variant: 'default', diff --git a/packages/universal/core-sdk/src/events/EventBuilder.ts b/packages/universal/core-sdk/src/events/EventBuilder.ts index 67a5b685..cf20da3b 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.ts @@ -243,6 +243,7 @@ export const TrackBuilderArgs = z.extend(UniversalEventBuilderArgs, { export type TrackBuilderArgs = z.infer export const NodeViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { + anonymousId: z.string(), entityId: z.string(), entityKind: z.union([ z.literal('Experience'), @@ -268,6 +269,21 @@ export const NodeViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { */ export type NodeViewBuilderArgs = z.infer +export const NodeViewTrackingArgs = z.extend(NodeViewBuilderArgs, { + anonymousId: z.optional(z.string()), +}) + +/** + * Arguments accepted by runtime `trackNodeView` callers. + * + * @remarks + * Runtime integrations may omit `anonymousId`; the emitter derives it from + * the active profile when not provided. + * + * @public + */ +export type NodeViewTrackingArgs = z.infer + /** * Default page properties used when no explicit page information is available. * @@ -739,6 +755,7 @@ class EventBuilder { */ buildNodeView(args: NodeViewBuilderArgs): NodeViewEvent { const { + anonymousId, entityId, entityKind, variant, @@ -755,6 +772,7 @@ class EventBuilder { return { ...this.buildUniversalEventProperties(universal), + anonymousId, type: 'exo_view', entityId, entityKind, From 3ec336b32d9ad5e40c2aff9495d35a80a725b1ee Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:32:48 +0200 Subject: [PATCH 06/16] feat(web-sdk): add configurable node view auto-tracking controls Introduces `autoTrackNodeInteraction` config option (consistent with `autoTrackEntryInteraction` naming) with a deprecated `autoTrackNodeViews` alias. Centralises consent + node view start/stop into `reconcileAutoTracking`. Adds per-element opt-out via `data-ctfl-track-node-views="false"` dataset attribute and exports `NODE_VIEW_SELECTOR` from the package index. Co-Authored-By: Claude Sonnet 4.6 --- .../web/web-sdk/src/ContentfulOptimization.ts | 84 +++++++++++++++++-- .../events/view/createNodeViewDetector.ts | 27 ++++-- .../entry-tracking/resolveTrackingPayload.ts | 2 + packages/web/web-sdk/src/index.ts | 1 + 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 2c2a9a26..87818333 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -97,6 +97,29 @@ export interface OptimizationWebConfig extends CoreStatefulConfig { */ autoTrackEntryInteraction?: AutoTrackEntryInteractionOptions + /** + * Controls automatic `exo_view` tracking behavior for node elements. + * + * @remarks + * Supports node interactions via the `views` interaction. + * + * @defaultValue `{ views: true }` + */ + autoTrackNodeInteraction?: AutoTrackNodeInteractionOptions + + /** + * Controls automatic `exo_view` tracking for elements carrying + * `data-ctfl-node-id`. + * + * @remarks + * Deprecated alias for `autoTrackNodeInteraction.views`. + * + * @defaultValue `true` + * + * @deprecated Use `autoTrackNodeInteraction` instead. + */ + autoTrackNodeViews?: boolean + /** * Cookie configuration used for persisting the anonymous identifier. * @@ -113,6 +136,29 @@ export interface OptimizationWebConfig extends CoreStatefulConfig { */ export type OptimizationTrackingApi = EntryInteractionApi +/** + * Union of tracked node interaction keys. + * + * @public + */ +export type NodeInteraction = 'views' + +/** + * Auto-tracking configuration for tracked node interactions. + * + * @public + */ +export type AutoTrackNodeInteractionOptions = Partial> + +function resolveAutoTrackNodeInteractionOptions( + options: AutoTrackNodeInteractionOptions | undefined, + legacyAutoTrackNodeViews: boolean | undefined, +): Record { + return { + views: options?.views ?? legacyAutoTrackNodeViews ?? true, + } +} + function resolveDefaultState( defaults: CoreStatefulConfig['defaults'] | undefined, ): NonNullable { @@ -212,6 +258,14 @@ class ContentfulOptimization extends CoreStateful { * @internal */ private readonly nodeViewRuntime: NodeViewRuntime + + /** + * Resolved automatic node-interaction tracking settings. + * + * @internal + */ + private readonly autoTrackNodeInteraction: Record + /** * Namespaced tracking controls for automatic and per-element entry interactions. * @@ -263,7 +317,12 @@ class ContentfulOptimization extends CoreStateful { if (typeof window !== 'undefined' && window.contentfulOptimization) throw new Error('ContentfulOptimization is already initialized') - const { autoTrackEntryInteraction, ...restConfig } = config + const { + autoTrackEntryInteraction, + autoTrackNodeInteraction, + autoTrackNodeViews, + ...restConfig + } = config const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) @@ -278,6 +337,10 @@ class ContentfulOptimization extends CoreStateful { this.tracking = tracking this.nodeViewRuntime = new NodeViewRuntime(this) + this.autoTrackNodeInteraction = resolveAutoTrackNodeInteractionOptions( + autoTrackNodeInteraction, + autoTrackNodeViews, + ) this.cookieAttributes = { domain: mergedConfig.cookie?.domain, @@ -305,13 +368,7 @@ class ContentfulOptimization extends CoreStateful { consent: { value }, } = signals - this.entryInteractionRuntime.syncAutoTrackedEntryInteractions(!!value) - - if (value) { - this.nodeViewRuntime.start() - } else { - this.nodeViewRuntime.stop() - } + this.reconcileAutoTracking(value) LocalStore.consent = value }) @@ -338,6 +395,17 @@ class ContentfulOptimization extends CoreStateful { if (typeof window !== 'undefined') window.contentfulOptimization ??= this } + private reconcileAutoTracking(consent: boolean | undefined): void { + this.entryInteractionRuntime.syncAutoTrackedEntryInteractions(!!consent) + + if (consent && this.autoTrackNodeInteraction.views) { + this.nodeViewRuntime.start() + return + } + + this.nodeViewRuntime.stop() + } + /** * Initialize anonymous ID state from cookies. * diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts index b0b1bcd1..defdc8ef 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -1,4 +1,4 @@ -import type { NodeViewBuilderArgs } from '@contentful/optimization-core' +import type { NodeViewTrackingArgs } from '@contentful/optimization-core' import { isHtmlOrSvgElement } from '../createTimedEntryDetector' import type { ElementViewCallbackInfo, @@ -12,7 +12,7 @@ import ElementViewObserver from './ElementViewObserver' * @internal */ export interface NodeViewTrackingCore { - trackNodeView: (payload: NodeViewBuilderArgs) => Promise + trackNodeView: (payload: NodeViewTrackingArgs) => Promise } /** @@ -29,7 +29,14 @@ export interface NodeViewDetector { disconnect: () => void } -function isKnownEntityKind(kind: string): kind is NodeViewBuilderArgs['entityKind'] { +function parseBooleanOverride(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + return undefined +} + +function isKnownEntityKind(kind: string): kind is NodeViewTrackingArgs['entityKind'] { return ( kind === 'Experience' || kind === 'Fragment' || @@ -41,13 +48,23 @@ function isKnownEntityKind(kind: string): kind is NodeViewBuilderArgs['entityKin function resolveNodeViewArgs( element: Element, info: ElementViewCallbackInfo, -): NodeViewBuilderArgs | undefined { +): NodeViewTrackingArgs | undefined { if (!isHtmlOrSvgElement(element)) return undefined const { - dataset: { ctflNodeId, ctflEntityId, ctflEntityKind, ctflOptimizationId, ctflVariant }, + dataset: { + ctflNodeId, + ctflEntityId, + ctflEntityKind, + ctflOptimizationId, + ctflTrackNodeViews, + ctflVariant, + }, } = element + const override = parseBooleanOverride(ctflTrackNodeViews) + if (override === false) return undefined + if (!ctflNodeId || !ctflEntityId || !ctflEntityKind || !ctflOptimizationId || !ctflVariant) return undefined diff --git a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts index 5909fa27..19941eed 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts @@ -35,6 +35,8 @@ export type CtflDataset = DOMStringMap & { ctflEntityKind?: string /** Resolved variant identifier for `exo_view` events. */ ctflVariant?: string + /** Optional per-element override for automatic node view tracking (`'true'`/`'false'`). */ + ctflTrackNodeViews?: 'true' | 'false' } /** diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index a8a61cc2..1c5c4b54 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -19,6 +19,7 @@ export { CAN_ADD_LISTENERS, ENTRY_SELECTOR, HAS_MUTATION_OBSERVER, + NODE_VIEW_SELECTOR, OPTIMIZATION_WEB_SDK_NAME, OPTIMIZATION_WEB_SDK_VERSION, } from './constants' From 4a3e7cc298e41b0913f52fbbb8e240b211a331e5 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:52:53 +0200 Subject: [PATCH 07/16] feat(web-sdk): propagate full node metadata through dataset attributes Expands ResolvedNodeMetadata to include all optional exo_view fields (entityKindId, entryIds, layers, parentExperienceId). resolveNodeViewPayload now builds the full ExoNodeLayer chain and walks ancestors to find the parent Experience. createNodeViewDetector reads the new dataset attributes (ctflEntityKindId, ctflEntryIds as CSV, ctflLayers as JSON, ctflParentExperienceId) and passes them through to trackNodeView. Co-Authored-By: Claude Sonnet 4.6 --- .../events/view/createNodeViewDetector.ts | 50 +++++++++++ .../resolveNodeViewPayload.test.ts | 57 ++++++++++++ .../entry-tracking/resolveNodeViewPayload.ts | 90 +++++++++++++++---- .../entry-tracking/resolveTrackingPayload.ts | 8 ++ 4 files changed, 188 insertions(+), 17 deletions(-) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts index defdc8ef..7b903152 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -1,4 +1,5 @@ import type { NodeViewTrackingArgs } from '@contentful/optimization-core' +import type { ExoNodeLayer } from '@contentful/optimization-core/api-schemas' import { isHtmlOrSvgElement } from '../createTimedEntryDetector' import type { ElementViewCallbackInfo, @@ -45,6 +46,47 @@ function isKnownEntityKind(kind: string): kind is NodeViewTrackingArgs['entityKi ) } +function parseEntryIds(value: string | undefined): string[] | undefined { + if (!value?.trim()) return undefined + const ids = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return ids.length > 0 ? ids : undefined +} + +function parseLayerValue(raw: unknown): ExoNodeLayer | undefined { + if (!raw || typeof raw !== 'object') return undefined + const { entityKind, entityId, variant, optimizationId } = raw as { + entityKind?: unknown + entityId?: unknown + variant?: unknown + optimizationId?: unknown + } + if (typeof entityKind !== 'string' || typeof entityId !== 'string') return undefined + if (!isKnownEntityKind(entityKind)) return undefined + return { + entityKind, + entityId, + variant: typeof variant === 'string' ? variant : undefined, + optimizationId: typeof optimizationId === 'string' ? optimizationId : undefined, + } +} + +function parseLayers(value: string | undefined): ExoNodeLayer[] | undefined { + if (!value?.trim()) return undefined + const parsed: unknown = (() => { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } + })() + if (!Array.isArray(parsed)) return undefined + const layers = parsed.map(parseLayerValue).filter((l): l is ExoNodeLayer => l !== undefined) + return layers.length > 0 ? layers : undefined +} + function resolveNodeViewArgs( element: Element, info: ElementViewCallbackInfo, @@ -59,6 +101,10 @@ function resolveNodeViewArgs( ctflOptimizationId, ctflTrackNodeViews, ctflVariant, + ctflEntityKindId, + ctflEntryIds, + ctflLayers, + ctflParentExperienceId, }, } = element @@ -77,6 +123,10 @@ function resolveNodeViewArgs( variant: ctflVariant, viewId: info.viewId, viewDurationMs: Math.max(0, Math.round(info.totalVisibleMs)), + entityKindId: ctflEntityKindId, + entryIds: parseEntryIds(ctflEntryIds), + layers: parseLayers(ctflLayers), + parentExperienceId: ctflParentExperienceId, } } diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts index f9f662ab..e0452da9 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts @@ -27,6 +27,15 @@ describe('resolveNodeViewPayload', () => { entityKind: 'Experience', optimizationId: 'exp-id', variant: 'variant-a', + layers: [ + { + entityKind: 'Experience', + entityId: 'exp-id', + variant: 'variant-a', + optimizationId: 'exp-id', + }, + ], + parentExperienceId: undefined, }) }) @@ -38,6 +47,21 @@ describe('resolveNodeViewPayload', () => { entityKind: 'Fragment', optimizationId: 'frag-id', variant: 'default', + layers: [ + { + entityKind: 'Fragment', + entityId: 'frag-id', + variant: 'default', + optimizationId: 'frag-id', + }, + { + entityKind: 'Experience', + entityId: 'exp-id', + variant: 'variant-a', + optimizationId: 'exp-id', + }, + ], + parentExperienceId: 'exp-id', }) }) @@ -78,6 +102,21 @@ describe('resolveNodeViewPayload', () => { entityKind: 'Experience', optimizationId: 'exp-id', variant: 'variant-b', + layers: [ + { + entityKind: 'Fragment', + entityId: 'frag-no-variants', + variant: undefined, + optimizationId: undefined, + }, + { + entityKind: 'Experience', + entityId: 'exp-id', + variant: 'variant-b', + optimizationId: 'exp-id', + }, + ], + parentExperienceId: undefined, }) }) @@ -109,4 +148,22 @@ describe('resolveNodeViewPayload', () => { expect(result).toBeUndefined() }) + + it('sets parentExperienceId to the nearest ancestor Experience layer above the attributed layer', () => { + const nestedSourceMap: SourceMap = { + variants: [{ type: 'personalization', id: 'variant-x' }], + layers: [ + { kind: 'Fragment', id: 'frag-id', variants: [0] }, + { kind: 'Experience', id: 'parent-exp-id' }, + ], + nodes: { + 'node-1': { layers: [0, 1], scope: 0 }, + }, + } + + const result = resolveNodeViewPayload('node-1', nestedSourceMap) + + expect(result?.parentExperienceId).toBe('parent-exp-id') + expect(result?.entityId).toBe('frag-id') + }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts index a442f990..45c96f66 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts @@ -1,5 +1,5 @@ import type { NodeViewBuilderArgs } from '@contentful/optimization-core' -import type { SourceMap } from '@contentful/optimization-core/api-schemas' +import type { ExoNodeLayer, SourceMap } from '@contentful/optimization-core/api-schemas' /** * Subset of {@link NodeViewBuilderArgs} that can be resolved from a sourceMap @@ -10,7 +10,14 @@ import type { SourceMap } from '@contentful/optimization-core/api-schemas' */ export type ResolvedNodeMetadata = Pick< NodeViewBuilderArgs, - 'entityId' | 'entityKind' | 'optimizationId' | 'variant' + | 'entityId' + | 'entityKind' + | 'optimizationId' + | 'variant' + | 'entityKindId' + | 'entryIds' + | 'layers' + | 'parentExperienceId' > const KNOWN_ENTITY_KINDS = new Set(['Experience', 'Fragment', 'InlineFragment', 'InlineComponent']) @@ -19,23 +26,65 @@ function isKnownEntityKind(kind: string): kind is ResolvedNodeMetadata['entityKi return KNOWN_ENTITY_KINDS.has(kind) } -function resolveLayerAtIndex( +function resolveExoLayer( layerIndex: number | undefined, layers: SourceMap['layers'], variants: SourceMap['variants'], -): ResolvedNodeMetadata | undefined { +): ExoNodeLayer | undefined { if (layerIndex === undefined) return undefined - const { [layerIndex]: layer } = layers - if (!layer?.variants?.length) return undefined - - const { [layer.variants[0] ?? -1]: variant } = variants - if (variant === undefined) return undefined - + if (!layer) return undefined const { kind, id } = layer if (!isKnownEntityKind(kind)) return undefined - return { entityId: id, entityKind: kind, optimizationId: id, variant: variant.id } + const firstVariantIndex = layer.variants?.[0] + const variantEntry = firstVariantIndex !== undefined ? variants[firstVariantIndex] : undefined + const variant = variantEntry?.id + const optimizationId = variantEntry !== undefined ? id : undefined + + return { entityKind: kind, entityId: id, variant, optimizationId } +} + +function resolveLayerChain( + nodeLayers: number[], + scopePosition: number, + layers: SourceMap['layers'], + variants: SourceMap['variants'], +): ExoNodeLayer[] { + const chain: ExoNodeLayer[] = [] + for (let i = scopePosition; i < nodeLayers.length; i++) { + const { [i]: layerIndex } = nodeLayers + const exoLayer = resolveExoLayer(layerIndex, layers, variants) + if (exoLayer !== undefined) chain.push(exoLayer) + } + return chain +} + +function findAttributableLayer( + nodeLayers: number[], + scopePosition: number, + layers: SourceMap['layers'], + variants: SourceMap['variants'], +): { layer: ExoNodeLayer; nodeIndex: number } | undefined { + for (let i = scopePosition; i < nodeLayers.length; i++) { + const { [i]: layerIndex } = nodeLayers + const exoLayer = resolveExoLayer(layerIndex, layers, variants) + if (exoLayer?.variant !== undefined) return { layer: exoLayer, nodeIndex: i } + } + return undefined +} + +function findParentExperienceId( + nodeLayers: number[], + attributedLayerNodeIndex: number, + layers: SourceMap['layers'], +): string | undefined { + for (let i = attributedLayerNodeIndex + 1; i < nodeLayers.length; i++) { + const { [i]: layerIndex } = nodeLayers + const { [layerIndex ?? -1]: layer } = layers + if (layer?.kind === 'Experience') return layer.id + } + return undefined } /** @@ -69,11 +118,18 @@ export function resolveNodeViewPayload( const scopePosition = nodeLayers.indexOf(scope) if (scopePosition < 0) return undefined - for (let i = scopePosition; i < nodeLayers.length; i++) { - const { [i]: layerIndex } = nodeLayers - const resolved = resolveLayerAtIndex(layerIndex, layers, variants) - if (resolved !== undefined) return resolved - } + const attributed = findAttributableLayer(nodeLayers, scopePosition, layers, variants) + if (attributed === undefined) return undefined - return undefined + const layerChain = resolveLayerChain(nodeLayers, scopePosition, layers, variants) + const parentExperienceId = findParentExperienceId(nodeLayers, attributed.nodeIndex, layers) + + return { + entityId: attributed.layer.entityId, + entityKind: attributed.layer.entityKind, + optimizationId: attributed.layer.optimizationId ?? attributed.layer.entityId, + variant: attributed.layer.variant ?? '', + layers: layerChain.length > 0 ? layerChain : undefined, + parentExperienceId, + } } diff --git a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts index 19941eed..50dea7ab 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts @@ -37,6 +37,14 @@ export type CtflDataset = DOMStringMap & { ctflVariant?: string /** Optional per-element override for automatic node view tracking (`'true'`/`'false'`). */ ctflTrackNodeViews?: 'true' | 'false' + /** Optional composite entity-kind identifier for `exo_view` events. */ + ctflEntityKindId?: string + /** Optional CSV of Contentful `sys.id` values for `exo_view` events. */ + ctflEntryIds?: string + /** Optional JSON-serialised `ExoNodeLayer[]` ancestor chain for `exo_view` events. */ + ctflLayers?: string + /** Optional parent Experience `sys.id` for nested `exo_view` events. */ + ctflParentExperienceId?: string } /** From e234240f0322276a92a8a869fc252e75b47d7899 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Wed, 13 May 2026 08:39:28 +0200 Subject: [PATCH 08/16] feat(react-web-sdk): add useOptimizedNode hook --- .../web/frameworks/react-web-sdk/src/index.ts | 6 ++ .../optimized-entry/useOptimizedNode.test.tsx | 92 +++++++++++++++++++ .../src/optimized-entry/useOptimizedNode.ts | 81 ++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx create mode 100644 packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index 30c70ed4..ce28f096 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -26,6 +26,12 @@ export type { UseOptimizedEntryParams, UseOptimizedEntryResult, } from './optimized-entry/useOptimizedEntry' +export { useOptimizedNode } from './optimized-entry/useOptimizedNode' +export type { + ResolvedNodeMetadata, + UseOptimizedNodeParams, + UseOptimizedNodeResult, +} from './optimized-entry/useOptimizedNode' export { LiveUpdatesProvider } from './provider/LiveUpdatesProvider' export type { LiveUpdatesProviderProps } from './provider/LiveUpdatesProvider' export { OptimizationProvider } from './provider/OptimizationProvider' diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx new file mode 100644 index 00000000..694f09fd --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx @@ -0,0 +1,92 @@ +import type { SourceMap } from '@contentful/optimization-web/api-schemas' +import { describe, expect, it } from '@rstest/core' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { useOptimizedNode, type UseOptimizedNodeResult } from './useOptimizedNode' + +const SOURCE_MAP: SourceMap = { + variants: [{ type: 'personalization', id: 'variant-a' }], + layers: [{ kind: 'Experience', id: 'exp-id', variants: [0] }], + nodes: { + 'node-1': { layers: [0], scope: 0 }, + }, +} + +function renderHook( + nodeId: string, + sourceMap: SourceMap, +): { getResult: () => UseOptimizedNodeResult; container: HTMLElement } { + let captured: UseOptimizedNodeResult | undefined = undefined + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + function Probe(): null { + captured = useOptimizedNode({ nodeId, sourceMap }) + return null + } + + act(() => { + root.render() + }) + + return { + getResult() { + if (!captured) throw new Error('hook result not captured') + return captured + }, + container, + } +} + +describe('useOptimizedNode', () => { + it('resolves payload when node is in sourceMap', () => { + const { getResult } = renderHook('node-1', SOURCE_MAP) + + expect(getResult().payload).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'exp-id', + variant: 'variant-a', + }) + }) + + it('returns undefined payload when nodeId is absent from sourceMap', () => { + const { getResult } = renderHook('nonexistent', SOURCE_MAP) + + expect(getResult().payload).toBeUndefined() + }) + + it('stamps dataset attributes when ref is called with an element', () => { + const { getResult } = renderHook('node-1', SOURCE_MAP) + const el = document.createElement('div') + + act(() => { + getResult().ref(el) + }) + + expect(el.dataset.ctflNodeId).toBe('node-1') + expect(el.dataset.ctflEntityId).toBe('exp-id') + expect(el.dataset.ctflEntityKind).toBe('Experience') + expect(el.dataset.ctflOptimizationId).toBe('exp-id') + expect(el.dataset.ctflVariant).toBe('variant-a') + }) + + it('ref is a no-op when payload is undefined', () => { + const { getResult } = renderHook('nonexistent', SOURCE_MAP) + const el = document.createElement('div') + + act(() => { + getResult().ref(el) + }) + + expect(el.dataset.ctflNodeId).toBeUndefined() + }) + + it('ref is a no-op when called with null', () => { + const { getResult } = renderHook('node-1', SOURCE_MAP) + const { ref } = getResult() + + expect(() => { ref(null) }).not.toThrow() + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts new file mode 100644 index 00000000..33bd4d3d --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts @@ -0,0 +1,81 @@ +import { + resolveNodeViewPayload, + type ResolvedNodeMetadata, +} from '@contentful/optimization-web' +import type { SourceMap } from '@contentful/optimization-web/api-schemas' +import { useCallback, useMemo } from 'react' + +export type { ResolvedNodeMetadata } + +export interface UseOptimizedNodeParams { + /** Rendered node ID matching a key in `sourceMap.nodes`. */ + nodeId: string + /** The `extensions.sourceMap` object from the XDA response. */ + sourceMap: SourceMap +} + +export interface UseOptimizedNodeResult { + /** + * Ref callback to attach to the DOM element that should be observed for + * viewport dwell. When called with a non-null element the function stamps the + * resolved node-view dataset attributes onto the element so the + * `NodeViewRuntime` can auto-detect it. + * + * @remarks + * Pass this ref to the root element rendered for the node, e.g.: + * ```tsx + * const { ref } = useOptimizedNode({ nodeId, sourceMap }) + * return
{children}
+ * ``` + */ + ref: (element: HTMLElement | SVGElement | null) => void + /** + * Resolved node metadata or `undefined` when the node is absent or has no + * attributable layer in the sourceMap. + */ + payload: ResolvedNodeMetadata | undefined +} + +/** + * Resolve XDA sourceMap metadata for a rendered node and return a ref callback + * that stamps the required `data-ctfl-*` attributes onto the host element. + * + * @remarks + * The stamped attributes are detected by the `NodeViewRuntime` for automatic + * `exo/node_view` viewport tracking — no manual tracking call is needed. + * + * When `payload` is `undefined` the ref callback is a no-op; the element will + * not be tracked. + * + * @param params - {@link UseOptimizedNodeParams} + * @returns {@link UseOptimizedNodeResult} + * + * @public + */ +export function useOptimizedNode({ + nodeId, + sourceMap, +}: UseOptimizedNodeParams): UseOptimizedNodeResult { + const payload = useMemo( + () => resolveNodeViewPayload(nodeId, sourceMap), + [nodeId, sourceMap], + ) + + const ref = useCallback( + (element: HTMLElement | SVGElement | null): void => { + if (!element || !payload) return + + const { dataset } = element + const { entityId, entityKind, optimizationId, variant } = payload + + dataset.ctflNodeId = nodeId + dataset.ctflEntityId = entityId + dataset.ctflEntityKind = entityKind + dataset.ctflOptimizationId = optimizationId + dataset.ctflVariant = variant + }, + [nodeId, payload], + ) + + return { ref, payload } +} From 31be7e0259df210dcb6473a0a016f5455531247b Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Wed, 13 May 2026 10:05:46 +0200 Subject: [PATCH 09/16] fix(react-web-sdk): clear stale node tracking attributes --- .../optimized-entry/useOptimizedNode.test.tsx | 44 +++++++++++++++---- .../src/optimized-entry/useOptimizedNode.ts | 13 +++++- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx index 694f09fd..6ec399a7 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx @@ -15,7 +15,7 @@ const SOURCE_MAP: SourceMap = { function renderHook( nodeId: string, sourceMap: SourceMap, -): { getResult: () => UseOptimizedNodeResult; container: HTMLElement } { +): { getResult: () => UseOptimizedNodeResult; cleanup: () => void } { let captured: UseOptimizedNodeResult | undefined = undefined const container = document.createElement('div') document.body.appendChild(container) @@ -35,13 +35,18 @@ function renderHook( if (!captured) throw new Error('hook result not captured') return captured }, - container, + cleanup() { + act(() => { + root.unmount() + }) + container.remove() + }, } } describe('useOptimizedNode', () => { it('resolves payload when node is in sourceMap', () => { - const { getResult } = renderHook('node-1', SOURCE_MAP) + const { cleanup, getResult } = renderHook('node-1', SOURCE_MAP) expect(getResult().payload).toEqual({ entityId: 'exp-id', @@ -49,16 +54,20 @@ describe('useOptimizedNode', () => { optimizationId: 'exp-id', variant: 'variant-a', }) + + cleanup() }) it('returns undefined payload when nodeId is absent from sourceMap', () => { - const { getResult } = renderHook('nonexistent', SOURCE_MAP) + const { cleanup, getResult } = renderHook('nonexistent', SOURCE_MAP) expect(getResult().payload).toBeUndefined() + + cleanup() }) it('stamps dataset attributes when ref is called with an element', () => { - const { getResult } = renderHook('node-1', SOURCE_MAP) + const { cleanup, getResult } = renderHook('node-1', SOURCE_MAP) const el = document.createElement('div') act(() => { @@ -70,23 +79,40 @@ describe('useOptimizedNode', () => { expect(el.dataset.ctflEntityKind).toBe('Experience') expect(el.dataset.ctflOptimizationId).toBe('exp-id') expect(el.dataset.ctflVariant).toBe('variant-a') + + cleanup() }) - it('ref is a no-op when payload is undefined', () => { - const { getResult } = renderHook('nonexistent', SOURCE_MAP) + it('clears node-view dataset attributes when payload is undefined', () => { + const { cleanup, getResult } = renderHook('nonexistent', SOURCE_MAP) const el = document.createElement('div') + el.dataset.ctflNodeId = 'previous-node' + el.dataset.ctflEntityId = 'previous-entity' + el.dataset.ctflEntityKind = 'Experience' + el.dataset.ctflOptimizationId = 'previous-optimization' + el.dataset.ctflVariant = 'previous-variant' act(() => { getResult().ref(el) }) expect(el.dataset.ctflNodeId).toBeUndefined() + expect(el.dataset.ctflEntityId).toBeUndefined() + expect(el.dataset.ctflEntityKind).toBeUndefined() + expect(el.dataset.ctflOptimizationId).toBeUndefined() + expect(el.dataset.ctflVariant).toBeUndefined() + + cleanup() }) it('ref is a no-op when called with null', () => { - const { getResult } = renderHook('node-1', SOURCE_MAP) + const { cleanup, getResult } = renderHook('node-1', SOURCE_MAP) const { ref } = getResult() - expect(() => { ref(null) }).not.toThrow() + expect(() => { + ref(null) + }).not.toThrow() + + cleanup() }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts index 33bd4d3d..8ce3fe17 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts @@ -63,9 +63,18 @@ export function useOptimizedNode({ const ref = useCallback( (element: HTMLElement | SVGElement | null): void => { - if (!element || !payload) return - + if (!element) return const { dataset } = element + + if (!payload) { + delete dataset.ctflNodeId + delete dataset.ctflEntityId + delete dataset.ctflEntityKind + delete dataset.ctflOptimizationId + delete dataset.ctflVariant + return + } + const { entityId, entityKind, optimizationId, variant } = payload dataset.ctflNodeId = nodeId From 563ebfd9913c99c94aa9b966ea8d034ee224679d Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:55:17 +0200 Subject: [PATCH 10/16] feat(react-web-sdk): stamp full node metadata in useOptimizedNode Expands the ref callback to serialize entityKindId, entryIds (as CSV), layers (as JSON), and parentExperienceId onto the element dataset so all optional exo_view fields are available to NodeViewRuntime. Clears the new attributes when payload is undefined. Co-Authored-By: Claude Sonnet 4.6 --- .../optimized-entry/useOptimizedNode.test.tsx | 25 +++++++++++++ .../src/optimized-entry/useOptimizedNode.ts | 35 +++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx index 6ec399a7..7afaef5a 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx @@ -53,6 +53,15 @@ describe('useOptimizedNode', () => { entityKind: 'Experience', optimizationId: 'exp-id', variant: 'variant-a', + layers: [ + { + entityKind: 'Experience', + entityId: 'exp-id', + optimizationId: 'exp-id', + variant: 'variant-a', + }, + ], + parentExperienceId: undefined, }) cleanup() @@ -79,6 +88,14 @@ describe('useOptimizedNode', () => { expect(el.dataset.ctflEntityKind).toBe('Experience') expect(el.dataset.ctflOptimizationId).toBe('exp-id') expect(el.dataset.ctflVariant).toBe('variant-a') + expect(JSON.parse(el.dataset.ctflLayers ?? '[]')).toEqual([ + { + entityKind: 'Experience', + entityId: 'exp-id', + optimizationId: 'exp-id', + variant: 'variant-a', + }, + ]) cleanup() }) @@ -89,7 +106,11 @@ describe('useOptimizedNode', () => { el.dataset.ctflNodeId = 'previous-node' el.dataset.ctflEntityId = 'previous-entity' el.dataset.ctflEntityKind = 'Experience' + el.dataset.ctflEntityKindId = 'previous-entity' + el.dataset.ctflEntryIds = 'a,b' + el.dataset.ctflLayers = '[]' el.dataset.ctflOptimizationId = 'previous-optimization' + el.dataset.ctflParentExperienceId = 'previous-parent' el.dataset.ctflVariant = 'previous-variant' act(() => { @@ -99,7 +120,11 @@ describe('useOptimizedNode', () => { expect(el.dataset.ctflNodeId).toBeUndefined() expect(el.dataset.ctflEntityId).toBeUndefined() expect(el.dataset.ctflEntityKind).toBeUndefined() + expect(el.dataset.ctflEntityKindId).toBeUndefined() + expect(el.dataset.ctflEntryIds).toBeUndefined() + expect(el.dataset.ctflLayers).toBeUndefined() expect(el.dataset.ctflOptimizationId).toBeUndefined() + expect(el.dataset.ctflParentExperienceId).toBeUndefined() expect(el.dataset.ctflVariant).toBeUndefined() cleanup() diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts index 8ce3fe17..f12ade84 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts @@ -1,7 +1,4 @@ -import { - resolveNodeViewPayload, - type ResolvedNodeMetadata, -} from '@contentful/optimization-web' +import { resolveNodeViewPayload, type ResolvedNodeMetadata } from '@contentful/optimization-web' import type { SourceMap } from '@contentful/optimization-web/api-schemas' import { useCallback, useMemo } from 'react' @@ -42,7 +39,7 @@ export interface UseOptimizedNodeResult { * * @remarks * The stamped attributes are detected by the `NodeViewRuntime` for automatic - * `exo/node_view` viewport tracking — no manual tracking call is needed. + * `exo_view` viewport tracking — no manual tracking call is needed. * * When `payload` is `undefined` the ref callback is a no-op; the element will * not be tracked. @@ -56,10 +53,7 @@ export function useOptimizedNode({ nodeId, sourceMap, }: UseOptimizedNodeParams): UseOptimizedNodeResult { - const payload = useMemo( - () => resolveNodeViewPayload(nodeId, sourceMap), - [nodeId, sourceMap], - ) + const payload = useMemo(() => resolveNodeViewPayload(nodeId, sourceMap), [nodeId, sourceMap]) const ref = useCallback( (element: HTMLElement | SVGElement | null): void => { @@ -70,17 +64,38 @@ export function useOptimizedNode({ delete dataset.ctflNodeId delete dataset.ctflEntityId delete dataset.ctflEntityKind + delete dataset.ctflEntityKindId + delete dataset.ctflEntryIds + delete dataset.ctflLayers delete dataset.ctflOptimizationId + delete dataset.ctflParentExperienceId delete dataset.ctflVariant return } - const { entityId, entityKind, optimizationId, variant } = payload + const { + entityId, + entityKind, + entityKindId, + entryIds, + layers, + optimizationId, + parentExperienceId, + variant, + } = payload dataset.ctflNodeId = nodeId dataset.ctflEntityId = entityId dataset.ctflEntityKind = entityKind + if (entityKindId !== undefined) dataset.ctflEntityKindId = entityKindId + else delete dataset.ctflEntityKindId + if (entryIds !== undefined) dataset.ctflEntryIds = entryIds.join(',') + else delete dataset.ctflEntryIds + if (layers !== undefined) dataset.ctflLayers = JSON.stringify(layers) + else delete dataset.ctflLayers dataset.ctflOptimizationId = optimizationId + if (parentExperienceId !== undefined) dataset.ctflParentExperienceId = parentExperienceId + else delete dataset.ctflParentExperienceId dataset.ctflVariant = variant }, [nodeId, payload], From 5ee64baaa8bc4878796e969de76542d572cc2e08 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 08:57:36 +0200 Subject: [PATCH 11/16] refactor(web-sdk): remove autoTrackNodeViews in favour of autoTrackNodeInteraction The deprecated alias was never shipped to main, so there is nothing to migrate. Removes the field from the config interface and collapses the two-arg resolveAutoTrackNodeInteractionOptions back to a single arg. Co-Authored-By: Claude Sonnet 4.6 --- .../web/web-sdk/src/ContentfulOptimization.ts | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 87818333..c26b37aa 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -107,19 +107,6 @@ export interface OptimizationWebConfig extends CoreStatefulConfig { */ autoTrackNodeInteraction?: AutoTrackNodeInteractionOptions - /** - * Controls automatic `exo_view` tracking for elements carrying - * `data-ctfl-node-id`. - * - * @remarks - * Deprecated alias for `autoTrackNodeInteraction.views`. - * - * @defaultValue `true` - * - * @deprecated Use `autoTrackNodeInteraction` instead. - */ - autoTrackNodeViews?: boolean - /** * Cookie configuration used for persisting the anonymous identifier. * @@ -152,10 +139,9 @@ export type AutoTrackNodeInteractionOptions = Partial { return { - views: options?.views ?? legacyAutoTrackNodeViews ?? true, + views: options?.views ?? true, } } @@ -317,12 +303,7 @@ class ContentfulOptimization extends CoreStateful { if (typeof window !== 'undefined' && window.contentfulOptimization) throw new Error('ContentfulOptimization is already initialized') - const { - autoTrackEntryInteraction, - autoTrackNodeInteraction, - autoTrackNodeViews, - ...restConfig - } = config + const { autoTrackEntryInteraction, autoTrackNodeInteraction, ...restConfig } = config const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) @@ -337,10 +318,7 @@ class ContentfulOptimization extends CoreStateful { this.tracking = tracking this.nodeViewRuntime = new NodeViewRuntime(this) - this.autoTrackNodeInteraction = resolveAutoTrackNodeInteractionOptions( - autoTrackNodeInteraction, - autoTrackNodeViews, - ) + this.autoTrackNodeInteraction = resolveAutoTrackNodeInteractionOptions(autoTrackNodeInteraction) this.cookieAttributes = { domain: mergedConfig.cookie?.domain, From bc512d0a8da66e10cf5ce9790a6a14be438b6d3f Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 14:08:08 +0200 Subject: [PATCH 12/16] refactor: improve readibility of `useOptimizedNode` --- .../src/optimized-entry/useOptimizedNode.ts | 63 ++++++++++++------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts index f12ade84..7b5b78b9 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts @@ -4,6 +4,30 @@ import { useCallback, useMemo } from 'react' export type { ResolvedNodeMetadata } +const CTFL_ATTRS = [ + 'data-ctfl-node-id', + 'data-ctfl-entity-id', + 'data-ctfl-entity-kind', + 'data-ctfl-entity-kind-id', + 'data-ctfl-entry-ids', + 'data-ctfl-layers', + 'data-ctfl-optimization-id', + 'data-ctfl-parent-experience-id', + 'data-ctfl-variant', +] as const + +function setOrRemoveAttr( + element: HTMLElement | SVGElement, + attr: string, + value: string | undefined, +): void { + if (value !== undefined) { + element.setAttribute(attr, value) + } else { + element.removeAttribute(attr) + } +} + export interface UseOptimizedNodeParams { /** Rendered node ID matching a key in `sourceMap.nodes`. */ nodeId: string @@ -57,19 +81,14 @@ export function useOptimizedNode({ const ref = useCallback( (element: HTMLElement | SVGElement | null): void => { - if (!element) return - const { dataset } = element + if (!element) { + return + } if (!payload) { - delete dataset.ctflNodeId - delete dataset.ctflEntityId - delete dataset.ctflEntityKind - delete dataset.ctflEntityKindId - delete dataset.ctflEntryIds - delete dataset.ctflLayers - delete dataset.ctflOptimizationId - delete dataset.ctflParentExperienceId - delete dataset.ctflVariant + for (const attr of CTFL_ATTRS) { + element.removeAttribute(attr) + } return } @@ -84,19 +103,15 @@ export function useOptimizedNode({ variant, } = payload - dataset.ctflNodeId = nodeId - dataset.ctflEntityId = entityId - dataset.ctflEntityKind = entityKind - if (entityKindId !== undefined) dataset.ctflEntityKindId = entityKindId - else delete dataset.ctflEntityKindId - if (entryIds !== undefined) dataset.ctflEntryIds = entryIds.join(',') - else delete dataset.ctflEntryIds - if (layers !== undefined) dataset.ctflLayers = JSON.stringify(layers) - else delete dataset.ctflLayers - dataset.ctflOptimizationId = optimizationId - if (parentExperienceId !== undefined) dataset.ctflParentExperienceId = parentExperienceId - else delete dataset.ctflParentExperienceId - dataset.ctflVariant = variant + element.setAttribute('data-ctfl-node-id', nodeId) + element.setAttribute('data-ctfl-entity-id', entityId) + element.setAttribute('data-ctfl-entity-kind', entityKind) + setOrRemoveAttr(element, 'data-ctfl-entity-kind-id', entityKindId) + setOrRemoveAttr(element, 'data-ctfl-entry-ids', entryIds?.join(',')) + setOrRemoveAttr(element, 'data-ctfl-layers', layers ? JSON.stringify(layers) : undefined) + element.setAttribute('data-ctfl-optimization-id', optimizationId) + setOrRemoveAttr(element, 'data-ctfl-parent-experience-id', parentExperienceId) + element.setAttribute('data-ctfl-variant', variant) }, [nodeId, payload], ) From 1b14810a3e76c14e8eda7d900b3dcbc2138cba80 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 14:25:08 +0200 Subject: [PATCH 13/16] refactor: improve `createNodeViewDetector` readiblility --- .../events/view/createNodeViewDetector.ts | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts index 7b903152..9dbb8c1a 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -47,11 +47,14 @@ function isKnownEntityKind(kind: string): kind is NodeViewTrackingArgs['entityKi } function parseEntryIds(value: string | undefined): string[] | undefined { - if (!value?.trim()) return undefined + if (!value?.trim()) { + return undefined + } const ids = value .split(',') .map((s) => s.trim()) .filter(Boolean) + return ids.length > 0 ? ids : undefined } @@ -73,16 +76,24 @@ function parseLayerValue(raw: unknown): ExoNodeLayer | undefined { } } +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } +} + function parseLayers(value: string | undefined): ExoNodeLayer[] | undefined { - if (!value?.trim()) return undefined - const parsed: unknown = (() => { - try { - return JSON.parse(value) as unknown - } catch { - return undefined - } - })() - if (!Array.isArray(parsed)) return undefined + if (!value?.trim()) { + return undefined + } + + const parsed = tryParseJson(value) + if (!Array.isArray(parsed)) { + return undefined + } + const layers = parsed.map(parseLayerValue).filter((l): l is ExoNodeLayer => l !== undefined) return layers.length > 0 ? layers : undefined } @@ -91,30 +102,34 @@ function resolveNodeViewArgs( element: Element, info: ElementViewCallbackInfo, ): NodeViewTrackingArgs | undefined { - if (!isHtmlOrSvgElement(element)) return undefined - - const { - dataset: { - ctflNodeId, - ctflEntityId, - ctflEntityKind, - ctflOptimizationId, - ctflTrackNodeViews, - ctflVariant, - ctflEntityKindId, - ctflEntryIds, - ctflLayers, - ctflParentExperienceId, - }, - } = element + if (!isHtmlOrSvgElement(element)) { + return undefined + } - const override = parseBooleanOverride(ctflTrackNodeViews) - if (override === false) return undefined + const { dataset } = element + if (parseBooleanOverride(dataset.ctflTrackNodeViews) === false) { + return undefined + } - if (!ctflNodeId || !ctflEntityId || !ctflEntityKind || !ctflOptimizationId || !ctflVariant) + const { + ctflNodeId, + ctflEntityId, + ctflEntityKind, + ctflOptimizationId, + ctflVariant, + ctflEntityKindId, + ctflEntryIds, + ctflLayers, + ctflParentExperienceId, + } = dataset + + if (!ctflNodeId || !ctflEntityId || !ctflEntityKind || !ctflOptimizationId || !ctflVariant) { return undefined + } - if (!isKnownEntityKind(ctflEntityKind)) return undefined + if (!isKnownEntityKind(ctflEntityKind)) { + return undefined + } return { entityId: ctflEntityId, From 7b4208a92fec40136228cede46308a4a9c8d8200 Mon Sep 17 00:00:00 2001 From: Samuel Durkin Date: Fri, 22 May 2026 14:41:39 +0200 Subject: [PATCH 14/16] refactor: improve readability of `refactor: improve readability of `resolveNodeViewPayload` --- .../entry-tracking/resolveNodeViewPayload.ts | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts index 45c96f66..446ecedb 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts @@ -31,16 +31,24 @@ function resolveExoLayer( layers: SourceMap['layers'], variants: SourceMap['variants'], ): ExoNodeLayer | undefined { - if (layerIndex === undefined) return undefined + if (layerIndex === undefined) { + return undefined + } + const { [layerIndex]: layer } = layers - if (!layer) return undefined + if (!layer) { + return undefined + } + const { kind, id } = layer - if (!isKnownEntityKind(kind)) return undefined + if (!isKnownEntityKind(kind)) { + return undefined + } const firstVariantIndex = layer.variants?.[0] const variantEntry = firstVariantIndex !== undefined ? variants[firstVariantIndex] : undefined const variant = variantEntry?.id - const optimizationId = variantEntry !== undefined ? id : undefined + const optimizationId = variantEntry ? id : undefined return { entityKind: kind, entityId: id, variant, optimizationId } } @@ -55,7 +63,9 @@ function resolveLayerChain( for (let i = scopePosition; i < nodeLayers.length; i++) { const { [i]: layerIndex } = nodeLayers const exoLayer = resolveExoLayer(layerIndex, layers, variants) - if (exoLayer !== undefined) chain.push(exoLayer) + if (exoLayer) { + chain.push(exoLayer) + } } return chain } @@ -69,7 +79,9 @@ function findAttributableLayer( for (let i = scopePosition; i < nodeLayers.length; i++) { const { [i]: layerIndex } = nodeLayers const exoLayer = resolveExoLayer(layerIndex, layers, variants) - if (exoLayer?.variant !== undefined) return { layer: exoLayer, nodeIndex: i } + if (exoLayer?.variant) { + return { layer: exoLayer, nodeIndex: i } + } } return undefined } @@ -82,7 +94,9 @@ function findParentExperienceId( for (let i = attributedLayerNodeIndex + 1; i < nodeLayers.length; i++) { const { [i]: layerIndex } = nodeLayers const { [layerIndex ?? -1]: layer } = layers - if (layer?.kind === 'Experience') return layer.id + if (layer?.kind === 'Experience') { + return layer.id + } } return undefined } @@ -112,14 +126,20 @@ export function resolveNodeViewPayload( ): ResolvedNodeMetadata | undefined { const { nodes, layers, variants } = sourceMap const { [nodeId]: node } = nodes - if (node === undefined) return undefined + if (node === undefined) { + return undefined + } const { layers: nodeLayers, scope } = node const scopePosition = nodeLayers.indexOf(scope) - if (scopePosition < 0) return undefined + if (scopePosition < 0) { + return undefined + } const attributed = findAttributableLayer(nodeLayers, scopePosition, layers, variants) - if (attributed === undefined) return undefined + if (attributed === undefined) { + return undefined + } const layerChain = resolveLayerChain(nodeLayers, scopePosition, layers, variants) const parentExperienceId = findParentExperienceId(nodeLayers, attributed.nodeIndex, layers) From ccc091a19c50a94a50870859deb180b0b7892d4b Mon Sep 17 00:00:00 2001 From: Bosun Egberinde Date: Wed, 27 May 2026 16:01:51 +0200 Subject: [PATCH 15/16] Drop layers, change variant to variantId --- package.json | 2 +- .../src/insights/event/NodeViewEvent.test.ts | 2 +- .../src/insights/event/NodeViewEvent.ts | 10 +---- .../core-sdk/src/CoreStatefulNodeView.test.ts | 2 +- .../core-sdk/src/events/EventBuilder.test.ts | 6 +-- .../core-sdk/src/events/EventBuilder.ts | 12 ++--- .../optimized-entry/useOptimizedNode.test.tsx | 18 +------- .../src/optimized-entry/useOptimizedNode.ts | 6 +-- .../events/view/createNodeViewDetector.ts | 45 +------------------ .../resolveNodeViewPayload.test.ts | 42 ++--------------- .../entry-tracking/resolveNodeViewPayload.ts | 30 +++---------- 11 files changed, 24 insertions(+), 151 deletions(-) diff --git a/package.json b/package.json index 24c98595..9333ba64 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:ci": "pnpm --filter @contentful/* --stream build:ci", "build:pkgs": "pnpm build && pnpm run pack:pkgs", "build:rsdoctor": "pnpm --filter @contentful/* --stream build:rsdoctor", - "clean": "pnpm -r --parallel clean", + "clean": "pnpm clean", "docs:generate": "typedoc", "docs:watch": "typedoc --watch", "format:check": "prettier . --check", diff --git a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts index b9686687..a9061680 100644 --- a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts @@ -22,7 +22,7 @@ const VALID_NODE_VIEW = { type: 'exo_view' as const, entityId: 'exp-sys-id', entityKind: 'Experience' as const, - variant: 'variant-a', + variantId: 'variant-a', optimizationId: 'opt-id', viewId: 'view-uuid', viewDurationMs: 1500, diff --git a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts index 484f808a..d8708f73 100644 --- a/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts @@ -16,7 +16,7 @@ const EntityKind = z.union([ export const ExoNodeLayer = z.object({ entityKind: EntityKind, entityId: z.string(), - variant: z.optional(z.string()), + variantId: z.optional(z.string()), optimizationId: z.optional(z.string()), }) @@ -69,7 +69,7 @@ export const NodeViewEvent = z.extend(UniversalEventProperties, { * @remarks * Resolved from `extensions.sourceMap.variants[].id`. */ - variant: z.string(), + variantId: z.string(), /** * Ninetailed experience (optimization) ID associated with this node. @@ -103,12 +103,6 @@ export const NodeViewEvent = z.extend(UniversalEventProperties, { */ entryIds: z.optional(z.array(z.string())), - /** - * Ancestor personalization layers resolved from the XDA sourceMap, ordered - * leaf-to-root. - */ - layers: z.optional(z.array(ExoNodeLayer)), - /** * Arbitrary key-value parameters captured at view time. */ diff --git a/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts index f366502d..ae607de4 100644 --- a/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts +++ b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts @@ -38,7 +38,7 @@ describe('CoreStateful.trackNodeView', () => { const nodeViewPayload: NodeViewTrackingArgs = { entityId: 'exp-sys-id', entityKind: 'Experience', - variant: 'variant-a', + variantId: 'variant-a', optimizationId: 'opt-id', viewId: 'view-uuid', viewDurationMs: 1500, diff --git a/packages/universal/core-sdk/src/events/EventBuilder.test.ts b/packages/universal/core-sdk/src/events/EventBuilder.test.ts index 6a28bf63..8000fd89 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.test.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.test.ts @@ -12,7 +12,7 @@ describe('EventBuilder.buildNodeView', () => { anonymousId: 'anon-id', entityId: 'exp-sys-id', entityKind: 'Experience', - variant: 'variant-a', + variantId: 'variant-a', optimizationId: 'opt-id', viewId: 'view-uuid', viewDurationMs: 1500, @@ -22,7 +22,7 @@ describe('EventBuilder.buildNodeView', () => { expect(event.anonymousId).toBe('anon-id') expect(event.entityId).toBe('exp-sys-id') expect(event.entityKind).toBe('Experience') - expect(event.variant).toBe('variant-a') + expect(event.variantId).toBe('variant-a') expect(event.optimizationId).toBe('opt-id') expect(event.viewId).toBe('view-uuid') expect(event.viewDurationMs).toBe(1500) @@ -34,7 +34,7 @@ describe('EventBuilder.buildNodeView', () => { anonymousId: 'anon-id', entityId: 'exp-id', entityKind: 'Fragment', - variant: 'default', + variantId: 'default', optimizationId: 'opt-id', viewId: 'view-uuid', viewDurationMs: 0, diff --git a/packages/universal/core-sdk/src/events/EventBuilder.ts b/packages/universal/core-sdk/src/events/EventBuilder.ts index cf20da3b..ffdc4a78 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.ts @@ -3,7 +3,6 @@ import { Campaign, type Channel, type ClickEvent, - type ExoNodeLayer, GeoLocation, type HoverEvent, type IdentifyEvent, @@ -251,13 +250,12 @@ export const NodeViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { z.literal('InlineFragment'), z.literal('InlineComponent'), ]), - variant: z.string(), + variantId: z.string(), optimizationId: z.string(), viewId: z.string(), viewDurationMs: z.number(), entityKindId: z.optional(z.string()), entryIds: z.optional(z.array(z.string())), - layers: z.optional(z.array(z.custom())), parameters: z.optional(z.record(z.string(), z.unknown())), parentExperienceId: z.optional(z.string()), }) @@ -744,7 +742,7 @@ class EventBuilder { * const event = builder.buildNodeView({ * entityId: 'experience-sys-id', * entityKind: 'Experience', - * variant: 'variant-a', + * variantId: 'variant-a', * optimizationId: 'optimization-id', * viewId: crypto.randomUUID(), * viewDurationMs: 1_000, @@ -758,13 +756,12 @@ class EventBuilder { anonymousId, entityId, entityKind, - variant, + variantId, optimizationId, viewId, viewDurationMs, entityKindId, entryIds, - layers, parameters, parentExperienceId, ...universal @@ -776,13 +773,12 @@ class EventBuilder { type: 'exo_view', entityId, entityKind, - variant, + variantId, optimizationId, viewId, viewDurationMs, entityKindId, entryIds, - layers, parameters, parentExperienceId, } diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx index 7afaef5a..e5eb94b6 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx @@ -52,15 +52,7 @@ describe('useOptimizedNode', () => { entityId: 'exp-id', entityKind: 'Experience', optimizationId: 'exp-id', - variant: 'variant-a', - layers: [ - { - entityKind: 'Experience', - entityId: 'exp-id', - optimizationId: 'exp-id', - variant: 'variant-a', - }, - ], + variantId: 'variant-a', parentExperienceId: undefined, }) @@ -88,14 +80,6 @@ describe('useOptimizedNode', () => { expect(el.dataset.ctflEntityKind).toBe('Experience') expect(el.dataset.ctflOptimizationId).toBe('exp-id') expect(el.dataset.ctflVariant).toBe('variant-a') - expect(JSON.parse(el.dataset.ctflLayers ?? '[]')).toEqual([ - { - entityKind: 'Experience', - entityId: 'exp-id', - optimizationId: 'exp-id', - variant: 'variant-a', - }, - ]) cleanup() }) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts index 7b5b78b9..0ca8e5d3 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts @@ -97,10 +97,9 @@ export function useOptimizedNode({ entityKind, entityKindId, entryIds, - layers, optimizationId, parentExperienceId, - variant, + variantId, } = payload element.setAttribute('data-ctfl-node-id', nodeId) @@ -108,10 +107,9 @@ export function useOptimizedNode({ element.setAttribute('data-ctfl-entity-kind', entityKind) setOrRemoveAttr(element, 'data-ctfl-entity-kind-id', entityKindId) setOrRemoveAttr(element, 'data-ctfl-entry-ids', entryIds?.join(',')) - setOrRemoveAttr(element, 'data-ctfl-layers', layers ? JSON.stringify(layers) : undefined) element.setAttribute('data-ctfl-optimization-id', optimizationId) setOrRemoveAttr(element, 'data-ctfl-parent-experience-id', parentExperienceId) - element.setAttribute('data-ctfl-variant', variant) + element.setAttribute('data-ctfl-variant', variantId) }, [nodeId, payload], ) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts index 9dbb8c1a..4d322f22 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -1,5 +1,4 @@ import type { NodeViewTrackingArgs } from '@contentful/optimization-core' -import type { ExoNodeLayer } from '@contentful/optimization-core/api-schemas' import { isHtmlOrSvgElement } from '../createTimedEntryDetector' import type { ElementViewCallbackInfo, @@ -58,46 +57,6 @@ function parseEntryIds(value: string | undefined): string[] | undefined { return ids.length > 0 ? ids : undefined } -function parseLayerValue(raw: unknown): ExoNodeLayer | undefined { - if (!raw || typeof raw !== 'object') return undefined - const { entityKind, entityId, variant, optimizationId } = raw as { - entityKind?: unknown - entityId?: unknown - variant?: unknown - optimizationId?: unknown - } - if (typeof entityKind !== 'string' || typeof entityId !== 'string') return undefined - if (!isKnownEntityKind(entityKind)) return undefined - return { - entityKind, - entityId, - variant: typeof variant === 'string' ? variant : undefined, - optimizationId: typeof optimizationId === 'string' ? optimizationId : undefined, - } -} - -function tryParseJson(value: string): unknown { - try { - return JSON.parse(value) as unknown - } catch { - return undefined - } -} - -function parseLayers(value: string | undefined): ExoNodeLayer[] | undefined { - if (!value?.trim()) { - return undefined - } - - const parsed = tryParseJson(value) - if (!Array.isArray(parsed)) { - return undefined - } - - const layers = parsed.map(parseLayerValue).filter((l): l is ExoNodeLayer => l !== undefined) - return layers.length > 0 ? layers : undefined -} - function resolveNodeViewArgs( element: Element, info: ElementViewCallbackInfo, @@ -119,7 +78,6 @@ function resolveNodeViewArgs( ctflVariant, ctflEntityKindId, ctflEntryIds, - ctflLayers, ctflParentExperienceId, } = dataset @@ -135,12 +93,11 @@ function resolveNodeViewArgs( entityId: ctflEntityId, entityKind: ctflEntityKind, optimizationId: ctflOptimizationId, - variant: ctflVariant, + variantId: ctflVariant, viewId: info.viewId, viewDurationMs: Math.max(0, Math.round(info.totalVisibleMs)), entityKindId: ctflEntityKindId, entryIds: parseEntryIds(ctflEntryIds), - layers: parseLayers(ctflLayers), parentExperienceId: ctflParentExperienceId, } } diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts index e0452da9..babdcaca 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts @@ -26,15 +26,7 @@ describe('resolveNodeViewPayload', () => { entityId: 'exp-id', entityKind: 'Experience', optimizationId: 'exp-id', - variant: 'variant-a', - layers: [ - { - entityKind: 'Experience', - entityId: 'exp-id', - variant: 'variant-a', - optimizationId: 'exp-id', - }, - ], + variantId: 'variant-a', parentExperienceId: undefined, }) }) @@ -46,21 +38,7 @@ describe('resolveNodeViewPayload', () => { entityId: 'frag-id', entityKind: 'Fragment', optimizationId: 'frag-id', - variant: 'default', - layers: [ - { - entityKind: 'Fragment', - entityId: 'frag-id', - variant: 'default', - optimizationId: 'frag-id', - }, - { - entityKind: 'Experience', - entityId: 'exp-id', - variant: 'variant-a', - optimizationId: 'exp-id', - }, - ], + variantId: 'default', parentExperienceId: 'exp-id', }) }) @@ -101,21 +79,7 @@ describe('resolveNodeViewPayload', () => { entityId: 'exp-id', entityKind: 'Experience', optimizationId: 'exp-id', - variant: 'variant-b', - layers: [ - { - entityKind: 'Fragment', - entityId: 'frag-no-variants', - variant: undefined, - optimizationId: undefined, - }, - { - entityKind: 'Experience', - entityId: 'exp-id', - variant: 'variant-b', - optimizationId: 'exp-id', - }, - ], + variantId: 'variant-b', parentExperienceId: undefined, }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts index 446ecedb..6dc0420e 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts @@ -13,10 +13,9 @@ export type ResolvedNodeMetadata = Pick< | 'entityId' | 'entityKind' | 'optimizationId' - | 'variant' + | 'variantId' | 'entityKindId' | 'entryIds' - | 'layers' | 'parentExperienceId' > @@ -47,27 +46,10 @@ function resolveExoLayer( const firstVariantIndex = layer.variants?.[0] const variantEntry = firstVariantIndex !== undefined ? variants[firstVariantIndex] : undefined - const variant = variantEntry?.id + const variantId = variantEntry?.id const optimizationId = variantEntry ? id : undefined - return { entityKind: kind, entityId: id, variant, optimizationId } -} - -function resolveLayerChain( - nodeLayers: number[], - scopePosition: number, - layers: SourceMap['layers'], - variants: SourceMap['variants'], -): ExoNodeLayer[] { - const chain: ExoNodeLayer[] = [] - for (let i = scopePosition; i < nodeLayers.length; i++) { - const { [i]: layerIndex } = nodeLayers - const exoLayer = resolveExoLayer(layerIndex, layers, variants) - if (exoLayer) { - chain.push(exoLayer) - } - } - return chain + return { entityKind: kind, entityId: id, variantId, optimizationId } } function findAttributableLayer( @@ -79,7 +61,7 @@ function findAttributableLayer( for (let i = scopePosition; i < nodeLayers.length; i++) { const { [i]: layerIndex } = nodeLayers const exoLayer = resolveExoLayer(layerIndex, layers, variants) - if (exoLayer?.variant) { + if (exoLayer?.variantId) { return { layer: exoLayer, nodeIndex: i } } } @@ -141,15 +123,13 @@ export function resolveNodeViewPayload( return undefined } - const layerChain = resolveLayerChain(nodeLayers, scopePosition, layers, variants) const parentExperienceId = findParentExperienceId(nodeLayers, attributed.nodeIndex, layers) return { entityId: attributed.layer.entityId, entityKind: attributed.layer.entityKind, optimizationId: attributed.layer.optimizationId ?? attributed.layer.entityId, - variant: attributed.layer.variant ?? '', - layers: layerChain.length > 0 ? layerChain : undefined, + variantId: attributed.layer.variantId ?? '', parentExperienceId, } } From cabdb967dd7d7e5de7355e62ef3286cd555cd742 Mon Sep 17 00:00:00 2001 From: Bosun Egberinde Date: Wed, 27 May 2026 16:16:07 +0200 Subject: [PATCH 16/16] Handle unavailable localStorage in web SDK --- .../web-sdk/src/storage/LocalStore.test.ts | 25 +++++++++++ .../web/web-sdk/src/storage/LocalStore.ts | 44 ++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/web/web-sdk/src/storage/LocalStore.test.ts b/packages/web/web-sdk/src/storage/LocalStore.test.ts index 2e92bb02..0535b465 100644 --- a/packages/web/web-sdk/src/storage/LocalStore.test.ts +++ b/packages/web/web-sdk/src/storage/LocalStore.test.ts @@ -16,6 +16,7 @@ describe('LocalStore', () => { afterEach(() => { rs.restoreAllMocks() + rs.unstubAllGlobals() }) it('deletes malformed JSON cache values', () => { @@ -54,6 +55,30 @@ describe('LocalStore', () => { expect(setSpy).toHaveBeenCalledTimes(1) }) + it('returns undefined when localStorage is unavailable', () => { + rs.stubGlobal('localStorage', undefined) + + expect(LocalStore.anonymousId).toBeUndefined() + expect(LocalStore.consent).toBeUndefined() + expect(LocalStore.debug).toBeUndefined() + expect(LocalStore.changes).toBeUndefined() + expect(() => { + LocalStore.setCache(CHANGES_CACHE_KEY, { foo: 'bar' }) + }).not.toThrow() + }) + + it('swallows localStorage.getItem failures during cache reads', () => { + const getSpy = rs.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('storage blocked') + }) + + expect(LocalStore.anonymousId).toBeUndefined() + expect(LocalStore.consent).toBeUndefined() + expect(LocalStore.debug).toBeUndefined() + expect(LocalStore.changes).toBeUndefined() + expect(getSpy).toHaveBeenCalled() + }) + it('prefers legacy anonymous id and clears legacy key', () => { localStorage.setItem(ANONYMOUS_ID_KEY_LEGACY, 'legacy-anon') localStorage.setItem(ANONYMOUS_ID_KEY, 'modern-anon') diff --git a/packages/web/web-sdk/src/storage/LocalStore.ts b/packages/web/web-sdk/src/storage/LocalStore.ts index 5d22c534..5a939b78 100644 --- a/packages/web/web-sdk/src/storage/LocalStore.ts +++ b/packages/web/web-sdk/src/storage/LocalStore.ts @@ -17,6 +17,30 @@ import type { z } from 'zod/mini' const logger = createScopedLogger('Web:LocalStore') +function getLocalStorage(): Storage | undefined { + try { + return typeof localStorage === 'undefined' ? undefined : localStorage + } catch (error) { + logger.warn('Failed to access localStorage', error) + return undefined + } +} + +function getStorageItem(key: string): string | null { + const storage = getLocalStorage() + + if (storage === undefined) { + return null + } + + try { + return storage.getItem(key) + } catch (error) { + logger.warn(`Failed to read localStorage key "${key}"`, error) + return null + } +} + /** * Local storage abstraction used by the Web SDK to persist optimization state. * @@ -55,11 +79,11 @@ const LocalStore = { * @returns The stored anonymous ID string, or `undefined` when absent. */ get anonymousId(): string | undefined { - const legacyAnonymousIdValue = localStorage.getItem(ANONYMOUS_ID_KEY_LEGACY) + const legacyAnonymousIdValue = getStorageItem(ANONYMOUS_ID_KEY_LEGACY) if (legacyAnonymousIdValue) LocalStore.setCache(ANONYMOUS_ID_KEY_LEGACY, undefined) - return legacyAnonymousIdValue ?? localStorage.getItem(ANONYMOUS_ID_KEY) ?? undefined + return legacyAnonymousIdValue ?? getStorageItem(ANONYMOUS_ID_KEY) ?? undefined }, /** @@ -78,7 +102,7 @@ const LocalStore = { * `denied`, or `undefined` when no value is stored. */ get consent(): boolean | undefined { - const consent = localStorage.getItem(CONSENT_KEY) + const consent = getStorageItem(CONSENT_KEY) switch (consent) { case 'accepted': @@ -107,7 +131,7 @@ const LocalStore = { * @returns `true` or `false` when stored, or `undefined` otherwise. */ get debug(): boolean | undefined { - const debug = localStorage.getItem(DEBUG_FLAG_KEY) + const debug = getStorageItem(DEBUG_FLAG_KEY) return debug ? debug === 'true' : undefined }, @@ -184,7 +208,7 @@ const LocalStore = { * @returns Parsed data when present and valid, otherwise `undefined`. */ getCache(key: string, parser: T): z.output | undefined { - const cacheString = localStorage.getItem(key) + const cacheString = getStorageItem(key) if (!cacheString) return @@ -208,11 +232,17 @@ const LocalStore = { * restricted storage environments (e.g. quota exhaustion, denied access). */ setCache(key: string, data: unknown): void { + const storage = getLocalStorage() + + if (storage === undefined) { + return + } + try { if (data === undefined) { - localStorage.removeItem(key) + storage.removeItem(key) } else { - localStorage.setItem(key, typeof data === 'string' ? data : JSON.stringify(data)) + storage.setItem(key, typeof data === 'string' ? data : JSON.stringify(data)) } } catch (error) { logger.warn(`Failed to persist localStorage key "${key}"`, error)