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/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..a9061680 --- /dev/null +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts @@ -0,0 +1,77 @@ +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, + anonymousId: 'anon-id', + type: 'exo_view' as const, + entityId: 'exp-sys-id', + entityKind: 'Experience' as const, + variantId: '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..d8708f73 --- /dev/null +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts @@ -0,0 +1,122 @@ +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(), + variantId: 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, { + /** + * Stable anonymous user identifier for this event. + */ + anonymousId: z.string(), + + /** + * 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`. + */ + variantId: 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())), + + /** + * 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' diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index 6d0c497c..8ad1dd52 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -21,6 +21,8 @@ import type { FlagViewBuilderArgs, HoverBuilderArgs, IdentifyBuilderArgs, + NodeViewBuilderArgs, + NodeViewTrackingArgs, PageViewBuilderArgs, ScreenViewBuilderArgs, TrackBuilderArgs, @@ -47,6 +49,7 @@ const CONSENT_EVENT_TYPE_MAP: Readonly>> = { trackFlagView: 'component', trackClick: 'component_click', trackHover: 'component_hover', + trackNodeView: 'exo_view', } /** @@ -287,6 +290,34 @@ 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: NodeViewTrackingArgs): Promise { + const anonymousId = payload.anonymousId ?? profileSignal.value?.id ?? '' + const builderArgs: NodeViewBuilderArgs = { ...payload, anonymousId } + + await this.sendInsightsEvent( + 'trackNodeView', + [payload], + this.eventBuilder.buildNodeView(builderArgs), + ) + } + 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..ae607de4 --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStatefulNodeView.test.ts @@ -0,0 +1,79 @@ +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import type { NodeViewTrackingArgs } 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: NodeViewTrackingArgs = { + entityId: 'exp-sys-id', + entityKind: 'Experience', + variantId: '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) + 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 () => { + 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..8000fd89 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.test.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.test.ts @@ -6,6 +6,49 @@ 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({ + anonymousId: 'anon-id', + entityId: 'exp-sys-id', + entityKind: 'Experience', + variantId: 'variant-a', + optimizationId: 'opt-id', + viewId: 'view-uuid', + viewDurationMs: 1500, + }) + + 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.variantId).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({ + anonymousId: 'anon-id', + entityId: 'exp-id', + entityKind: 'Fragment', + variantId: '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..ffdc4a78 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.ts @@ -7,6 +7,7 @@ import { type HoverEvent, type IdentifyEvent, type Library, + type NodeViewEvent, Page, PageEventContext, type PageViewEvent, @@ -240,6 +241,47 @@ 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'), + z.literal('Fragment'), + z.literal('InlineFragment'), + z.literal('InlineComponent'), + ]), + variantId: z.string(), + optimizationId: z.string(), + viewId: z.string(), + viewDurationMs: z.number(), + entityKindId: z.optional(z.string()), + entryIds: z.optional(z.array(z.string())), + 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 + +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. * @@ -687,6 +729,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', + * variantId: 'variant-a', + * optimizationId: 'optimization-id', + * viewId: crypto.randomUUID(), + * viewDurationMs: 1_000, + * }) + * ``` + * + * @public + */ + buildNodeView(args: NodeViewBuilderArgs): NodeViewEvent { + const { + anonymousId, + entityId, + entityKind, + variantId, + optimizationId, + viewId, + viewDurationMs, + entityKindId, + entryIds, + parameters, + parentExperienceId, + ...universal + } = parseWithFriendlyError(NodeViewBuilderArgs, args) + + return { + ...this.buildUniversalEventProperties(universal), + anonymousId, + type: 'exo_view', + entityId, + entityKind, + variantId, + optimizationId, + viewId, + viewDurationMs, + entityKindId, + entryIds, + parameters, + parentExperienceId, + } + } } export default EventBuilder 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..e5eb94b6 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.test.tsx @@ -0,0 +1,127 @@ +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; cleanup: () => void } { + 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 + }, + cleanup() { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('useOptimizedNode', () => { + it('resolves payload when node is in sourceMap', () => { + const { cleanup, getResult } = renderHook('node-1', SOURCE_MAP) + + expect(getResult().payload).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'exp-id', + variantId: 'variant-a', + parentExperienceId: undefined, + }) + + cleanup() + }) + + it('returns undefined payload when nodeId is absent from sourceMap', () => { + const { cleanup, getResult } = renderHook('nonexistent', SOURCE_MAP) + + expect(getResult().payload).toBeUndefined() + + cleanup() + }) + + it('stamps dataset attributes when ref is called with an element', () => { + const { cleanup, 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') + + cleanup() + }) + + 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.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(() => { + getResult().ref(el) + }) + + 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() + }) + + it('ref is a no-op when called with null', () => { + const { cleanup, getResult } = renderHook('node-1', SOURCE_MAP) + const { ref } = getResult() + + 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 new file mode 100644 index 00000000..0ca8e5d3 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedNode.ts @@ -0,0 +1,118 @@ +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 } + +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 + /** 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_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) { + return + } + + if (!payload) { + for (const attr of CTFL_ATTRS) { + element.removeAttribute(attr) + } + return + } + + const { + entityId, + entityKind, + entityKindId, + entryIds, + optimizationId, + parentExperienceId, + variantId, + } = payload + + 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(',')) + element.setAttribute('data-ctfl-optimization-id', optimizationId) + setOrRemoveAttr(element, 'data-ctfl-parent-experience-id', parentExperienceId) + element.setAttribute('data-ctfl-variant', variantId) + }, + [nodeId, payload], + ) + + return { ref, payload } +} diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 42cf2de9..c26b37aa 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, @@ -96,6 +97,16 @@ 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 + /** * Cookie configuration used for persisting the anonymous identifier. * @@ -112,6 +123,28 @@ 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, +): Record { + return { + views: options?.views ?? true, + } +} + function resolveDefaultState( defaults: CoreStatefulConfig['defaults'] | undefined, ): NonNullable { @@ -204,6 +237,21 @@ class ContentfulOptimization extends CoreStateful { * @internal */ private readonly entryInteractionRuntime: EntryInteractionRuntime + + /** + * Runtime for automatic `exo_view` viewport tracking. + * + * @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. * @@ -255,7 +303,7 @@ class ContentfulOptimization extends CoreStateful { if (typeof window !== 'undefined' && window.contentfulOptimization) throw new Error('ContentfulOptimization is already initialized') - const { autoTrackEntryInteraction, ...restConfig } = config + const { autoTrackEntryInteraction, autoTrackNodeInteraction, ...restConfig } = config const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) @@ -269,6 +317,9 @@ class ContentfulOptimization extends CoreStateful { this.entryInteractionRuntime = entryInteractionRuntime this.tracking = tracking + this.nodeViewRuntime = new NodeViewRuntime(this) + this.autoTrackNodeInteraction = resolveAutoTrackNodeInteractionOptions(autoTrackNodeInteraction) + this.cookieAttributes = { domain: mergedConfig.cookie?.domain, expires: mergedConfig.cookie?.expires ?? EXPIRATION_DAYS_DEFAULT, @@ -295,7 +346,8 @@ class ContentfulOptimization extends CoreStateful { consent: { value }, } = signals - this.entryInteractionRuntime.syncAutoTrackedEntryInteractions(!!value) + this.reconcileAutoTracking(value) + LocalStore.consent = value }) @@ -321,6 +373,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. * @@ -380,6 +443,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 +458,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..4d322f22 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -0,0 +1,141 @@ +import type { NodeViewTrackingArgs } 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: NodeViewTrackingArgs) => 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 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' || + kind === 'InlineFragment' || + kind === 'InlineComponent' + ) +} + +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 resolveNodeViewArgs( + element: Element, + info: ElementViewCallbackInfo, +): NodeViewTrackingArgs | undefined { + if (!isHtmlOrSvgElement(element)) { + return undefined + } + + const { dataset } = element + if (parseBooleanOverride(dataset.ctflTrackNodeViews) === false) { + return undefined + } + + const { + ctflNodeId, + ctflEntityId, + ctflEntityKind, + ctflOptimizationId, + ctflVariant, + ctflEntityKindId, + ctflEntryIds, + ctflParentExperienceId, + } = dataset + + if (!ctflNodeId || !ctflEntityId || !ctflEntityKind || !ctflOptimizationId || !ctflVariant) { + return undefined + } + + if (!isKnownEntityKind(ctflEntityKind)) { + return undefined + } + + return { + entityId: ctflEntityId, + entityKind: ctflEntityKind, + optimizationId: ctflOptimizationId, + variantId: ctflVariant, + viewId: info.viewId, + viewDurationMs: Math.max(0, Math.round(info.totalVisibleMs)), + entityKindId: ctflEntityKindId, + entryIds: parseEntryIds(ctflEntryIds), + parentExperienceId: ctflParentExperienceId, + } +} + +/** + * 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..babdcaca --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts @@ -0,0 +1,133 @@ +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', + variantId: 'variant-a', + parentExperienceId: undefined, + }) + }) + + 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', + variantId: 'default', + parentExperienceId: 'exp-id', + }) + }) + + 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', + variantId: 'variant-b', + parentExperienceId: undefined, + }) + }) + + 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() + }) + + 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 new file mode 100644 index 00000000..6dc0420e --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts @@ -0,0 +1,135 @@ +import type { NodeViewBuilderArgs } from '@contentful/optimization-core' +import type { ExoNodeLayer, 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' + | 'variantId' + | 'entityKindId' + | 'entryIds' + | 'parentExperienceId' +> + +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 resolveExoLayer( + layerIndex: number | undefined, + layers: SourceMap['layers'], + variants: SourceMap['variants'], +): ExoNodeLayer | undefined { + if (layerIndex === undefined) { + return undefined + } + + const { [layerIndex]: layer } = layers + if (!layer) { + return undefined + } + + const { kind, id } = layer + if (!isKnownEntityKind(kind)) { + return undefined + } + + const firstVariantIndex = layer.variants?.[0] + const variantEntry = firstVariantIndex !== undefined ? variants[firstVariantIndex] : undefined + const variantId = variantEntry?.id + const optimizationId = variantEntry ? id : undefined + + return { entityKind: kind, entityId: id, variantId, optimizationId } +} + +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?.variantId) { + 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 +} + +/** + * 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 + } + + const attributed = findAttributableLayer(nodeLayers, scopePosition, layers, variants) + if (attributed === undefined) { + return undefined + } + + const parentExperienceId = findParentExperienceId(nodeLayers, attributed.nodeIndex, layers) + + return { + entityId: attributed.layer.entityId, + entityKind: attributed.layer.entityKind, + optimizationId: attributed.layer.optimizationId ?? attributed.layer.entityId, + variantId: attributed.layer.variantId ?? '', + parentExperienceId, + } +} diff --git a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts index 3d554274..50dea7ab 100644 --- a/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts +++ b/packages/web/web-sdk/src/entry-tracking/resolveTrackingPayload.ts @@ -24,6 +24,27 @@ 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 + /** 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 } /** diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index 9ef9c4f9..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' @@ -40,6 +41,7 @@ export type { EntryViewInteractionElementOptions, EntryViewInteractionStartOptions, } from './entry-tracking' +export * from './entry-tracking/resolveNodeViewPayload' export * from './handlers/beaconHandler' export * from './storage/LocalStore' 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)