diff --git a/packages/universal/api-schemas/src/experience/index.ts b/packages/universal/api-schemas/src/experience/index.ts index 0b885a5a8..495b75912 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.ts b/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts new file mode 100644 index 000000000..cf011eeac --- /dev/null +++ b/packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts @@ -0,0 +1,131 @@ +import * as z from 'zod/mini' + +/** + * Header/opt-in constant for requesting `extensions.sourceMap` from XDA. + * + * @remarks + * Setting either this header or `extensions.sourceMap: {}` on the request + * body signals opt-in. + * + * @public + */ +export const EXTENSIONS_SOURCE_MAP_HEADER = 'x-contentful-extensions-sourcemap' as const + +/** + * Zod schema for a variant row in the source-map, currently only the + * `personalization` type is emitted. + * + * @remarks + * The wire authority is `@contentful/view-delivery-contract`; this schema is + * a subset of it, covering only the fields read by + * {@link resolveNodeViewPayload}. + * + * @public + */ +export const SourceMapVariant = z.object({ + type: z.literal('personalization'), + id: z.string(), + experienceId: z.optional(z.string()), + optimizationId: z.optional(z.string()), + variantId: z.optional(z.string()), + variantIndex: z.optional(z.number()), +}) + +/** + * TypeScript type inferred from {@link SourceMapVariant}. + * + * @public + */ +export type SourceMapVariant = z.infer + +/** + * Zod schema for a layer row in the source-map. Discriminated on `kind`. + * + * @remarks + * Only `Experience` and `Fragment` layers carry a `variants` reference and + * are considered attributable by + * {@link resolveNodeViewPayload}. The remaining kinds (`ComponentType`, + * `Template`, `Slot`, `InlineFragment`) are accepted so `.parse` does not + * fail on future-added fields. + * + * @public + */ +export const SourceMapLayer = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('ComponentType'), id: z.string() }), + z.object({ kind: z.literal('Template'), id: z.string() }), + z.object({ kind: z.literal('Slot'), id: z.string() }), + z.object({ + kind: z.literal('Experience'), + id: z.string(), + variants: z.array(z.number()), + dataAssembly: z.optional(z.number()), + }), + z.object({ + kind: z.literal('Fragment'), + id: z.string(), + variants: z.array(z.number()), + dataAssembly: z.optional(z.number()), + }), + z.object({ + kind: z.literal('InlineFragment'), + id: z.string(), + dataAssembly: z.optional(z.number()), + }), +]) + +/** + * TypeScript type inferred from {@link SourceMapLayer}. + * + * @public + */ +export type SourceMapLayer = z.infer + +/** + * Zod schema for a per-hydrated-node mapping row. + * + * @remarks + * `layers` runs leaf-to-root; `scope` indexes the nearest data-context + * boundary layer used for entity-id attribution. + * + * @public + */ +export const SourceMapNode = z.object({ + layers: z.array(z.number()), + scope: z.number(), + contentProperties: z.array(z.unknown()), +}) + +/** + * TypeScript type inferred from {@link SourceMapNode}. + * + * @public + */ +export type SourceMapNode = z.infer + +/** + * Zod schema for the top-level source-map payload. Field names mirror + * `DeliveryViewSourceMapSchema` from `@contentful/view-delivery-contract` so + * `.parse` will not fail on future-added fields; only `variants`, `layers`, + * and `nodes` are read by consumers in this package. + * + * @public + */ +export const SourceMap = z.object({ + version: z.literal(1), + variants: z.array(SourceMapVariant), + spaces: z.array(z.string()), + environments: z.array(z.string()), + locales: z.array(z.string()), + entries: z.array(z.unknown()), + assets: z.array(z.unknown()), + layers: z.array(SourceMapLayer), + dataAssemblies: z.array(z.unknown()), + 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 000000000..c9f5e6b43 --- /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 44f3a5e71..fb8e960b3 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}. + * Insights events include {@link ViewEvent}, {@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.ts b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts new file mode 100644 index 000000000..6007c3a41 --- /dev/null +++ b/packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts @@ -0,0 +1,104 @@ +import * as z from 'zod/mini' +import { UniversalEventProperties } from '../../experience/event/UniversalEventProperties' + +/** + * Structural kinds an `exo_node_view` event can be attributed to. + * + * @remarks + * Narrowed to the two kinds that carry a `variants` reference in the XDA + * source-map contract. `InlineFragment` and structural rows are never + * attributable and are therefore not surfaced here. + * + * @public + */ +const NodeViewEntityKind = z.union([z.literal('Experience'), z.literal('Fragment')]) + +/** + * Zod schema describing an `exo_node_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_node_view'), + + /** + * `sys.id` of the Experience or Fragment that owns this node. + */ + entityId: z.string(), + + /** + * Structural kind of the owning entity. + */ + entityKind: NodeViewEntityKind, + + /** + * Variant identifier selected for this node. + * + * @remarks + * Resolved from `extensions.sourceMap.variants[].variantId`, falling back to + * `extensions.sourceMap.variants[].id`. + */ + variantId: z.string(), + + /** + * Variant index selected for this node. + * + * @remarks + * Resolved from `extensions.sourceMap.variants[].variantIndex`, falling back + * to the selected `extensions.sourceMap.layers[].variants[]` reference. The + * default variant is index `0`. + */ + variantIndex: z.number(), + + /** + * 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(), + + /** + * `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 f3a2a2ac5..adf372349 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/CoreStateful.personalization.test.ts b/packages/universal/core-sdk/src/CoreStateful.personalization.test.ts new file mode 100644 index 000000000..26afdcdea --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStateful.personalization.test.ts @@ -0,0 +1,193 @@ +import type { OptimizationData } from '@contentful/optimization-api-schemas' +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import { batch, signals } from './signals' +import { profile as profileFixture } from './test/fixtures/profile' +import { selectedOptimizations as selectedOptimizationsFixture } from './test/fixtures/selectedOptimizations' + +const config: CoreStatefulConfig = { + clientId: 'key_123', + environment: 'main', +} + +class CoreStatefulTestHarness extends CoreStateful { + setOnlineState(isOnline: boolean): void { + this.online = isOnline + } +} + +describe('CoreStateful personalization request/response', () => { + const createdCores: CoreStateful[] = [] + + const createCore = (overrides: Partial = {}): CoreStatefulTestHarness => { + const core = new CoreStatefulTestHarness({ ...config, ...overrides }) + createdCores.push(core) + return core + } + + beforeEach(() => { + batch(() => { + signals.blockedEvent.value = undefined + signals.changes.value = undefined + signals.consent.value = undefined + signals.event.value = undefined + signals.experienceRequestState.value = { status: 'idle' } + signals.online.value = true + signals.persistenceConsent.value = undefined + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + }) + }) + + afterEach(() => { + while (createdCores.length > 0) { + const core = createdCores.pop() + core?.destroy() + } + }) + + describe('getPersonalizationRequest', () => { + it('returns { events: [] } when nothing is queued and no profile is held', () => { + const core = createCore({ defaults: { consent: true } }) + + const request = core.getPersonalizationRequest() + + expect(request).toEqual({ events: [] }) + }) + + it('includes profileId from the current profile signal when present', () => { + const core = createCore({ + defaults: { consent: true, profile: { ...profileFixture, id: 'profile-abc' } }, + }) + + const request = core.getPersonalizationRequest() + + expect(request).toEqual({ profileId: 'profile-abc', events: [] }) + }) + + it('drains queued Experience events when consent is granted', async () => { + const core = createCore({ defaults: { consent: true } }) + + core.setOnlineState(false) + await core.track({ event: 'e1' }) + await core.track({ event: 'e2' }) + + const { events } = core.getPersonalizationRequest() + + expect(events).toHaveLength(2) + expect(events[0]).toMatchObject({ type: 'track', event: 'e1' }) + expect(events[1]).toMatchObject({ type: 'track', event: 'e2' }) + + // Second call should find the queue empty — drain clears it. + expect(core.getPersonalizationRequest().events).toEqual([]) + }) + + it('leaves events queued and reports a blocked event when consent is not granted', async () => { + const onEventBlocked = rs.fn() + const core = createCore({ + defaults: { consent: true }, + onEventBlocked, + }) + + core.setOnlineState(false) + await core.track({ event: 'e1' }) + + // Revoke consent by writing the signal directly. The public + // `core.consent(false)` API purges the queue as a side effect; we want + // to observe the drain gate's own behavior when the queue still holds + // events at consent-revocation time. + signals.consent.value = false + + const request = core.getPersonalizationRequest() + + expect(request.events).toEqual([]) + expect(onEventBlocked).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'consent', + method: 'getPersonalizationRequest', + }), + ) + + // Restore consent — the queued event should still be drainable. + signals.consent.value = true + const retried = core.getPersonalizationRequest() + + expect(retried.events).toHaveLength(1) + expect(retried.events[0]).toMatchObject({ type: 'track', event: 'e1' }) + }) + + it('is unblocked when the allow-list opts in to any personalization event type', async () => { + const core = createCore({ allowedEventTypes: ['identify'] }) + + core.setOnlineState(false) + await core.identify({ userId: 'user-1' }) + + const request = core.getPersonalizationRequest() + + expect(request.events).toHaveLength(1) + expect(request.events[0]).toMatchObject({ type: 'identify' }) + }) + }) + + describe('ingestPersonalizationResponse', () => { + const responseData: OptimizationData = { + profile: { ...profileFixture, id: 'profile-xyz' }, + selectedOptimizations: selectedOptimizationsFixture, + changes: [ + { + key: 'dark-mode', + type: 'Variable', + value: true, + meta: { experienceId: 'exp-1', variantIndex: 0 }, + }, + ], + } + + it('applies profile, selectedOptimizations, and marks the request state success', async () => { + const core = createCore({ defaults: { consent: true } }) + + core.ingestPersonalizationResponse(responseData) + await Promise.resolve() + + expect(signals.profile.value?.id).toBe('profile-xyz') + expect(signals.selectedOptimizations.value).toEqual(selectedOptimizationsFixture) + expect(signals.experienceRequestState.value).toEqual({ status: 'success' }) + }) + + it('is a no-op when called with undefined', () => { + const core = createCore({ defaults: { consent: true } }) + const before = { + profile: signals.profile.value, + selectedOptimizations: signals.selectedOptimizations.value, + experienceRequestState: signals.experienceRequestState.value, + } + + core.ingestPersonalizationResponse(undefined) + + expect(signals.profile.value).toBe(before.profile) + expect(signals.selectedOptimizations.value).toBe(before.selectedOptimizations) + expect(signals.experienceRequestState.value).toBe(before.experienceRequestState) + }) + + it('is idempotent — repeated calls with the same payload do not re-notify subscribers', async () => { + const core = createCore({ defaults: { consent: true } }) + core.ingestPersonalizationResponse(responseData) + await Promise.resolve() + + const profileUpdates: Array = [] + const subscription = core.states.profile.subscribe((value) => { + profileUpdates.push(value?.id) + }) + + // subscribe fires once with the current value + expect(profileUpdates).toEqual(['profile-xyz']) + + core.ingestPersonalizationResponse(responseData) + await Promise.resolve() + + // No additional emission — signal writes are equality-gated. + expect(profileUpdates).toEqual(['profile-xyz']) + + subscription.unsubscribe() + }) + }) +}) diff --git a/packages/universal/core-sdk/src/CoreStateful.trackNodeView.test.ts b/packages/universal/core-sdk/src/CoreStateful.trackNodeView.test.ts new file mode 100644 index 000000000..f2cdeb0aa --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStateful.trackNodeView.test.ts @@ -0,0 +1,156 @@ +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import type { BlockedEvent, NodeViewTrackingArgs } from './events' +import { batch, signals } from './signals' +import { profile as profileFixture } from './test/fixtures/profile' + +const config: CoreStatefulConfig = { + clientId: 'key_123', + environment: 'main', +} + +const BASE_PAYLOAD: NodeViewTrackingArgs = { + entityId: 'entity-1', + entityKind: 'Experience', + variantId: 'variant-a', + variantIndex: 1, + optimizationId: 'opt-1', + viewId: 'view-1', + viewDurationMs: 250, +} + +class CoreStatefulTestHarness extends CoreStateful { + setOnlineState(isOnline: boolean): void { + this.online = isOnline + } +} + +describe('CoreStateful.trackNodeView', () => { + const createdCores: CoreStateful[] = [] + + const createCore = (overrides: Partial = {}): CoreStatefulTestHarness => { + const core = new CoreStatefulTestHarness({ ...config, ...overrides }) + createdCores.push(core) + return core + } + + beforeEach(() => { + batch(() => { + signals.blockedEvent.value = undefined + signals.changes.value = undefined + signals.consent.value = undefined + signals.event.value = undefined + signals.online.value = true + signals.persistenceConsent.value = undefined + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + }) + }) + + afterEach(() => { + while (createdCores.length > 0) { + const core = createdCores.pop() + core?.destroy() + } + rs.restoreAllMocks() + }) + + it('emits an exo_node_view Insights event carrying the payload fields when consent is granted', async () => { + const core = createCore({ + defaults: { consent: true, profile: profileFixture }, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackNodeView(BASE_PAYLOAD) + await core.flush() + + expect(sendBatchEvents).toHaveBeenCalledTimes(1) + const insightsEvent = sendBatchEvents.mock.calls[0]?.[0][0]?.events[0] + expect(insightsEvent).toEqual( + expect.objectContaining({ + type: 'exo_node_view', + entityId: 'entity-1', + entityKind: 'Experience', + variantId: 'variant-a', + variantIndex: 1, + optimizationId: 'opt-1', + viewId: 'view-1', + viewDurationMs: 250, + }), + ) + }) + + it('defaults anonymousId to the active profile id when the caller omits it', async () => { + const core = createCore({ + defaults: { consent: true, profile: profileFixture }, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackNodeView(BASE_PAYLOAD) + await core.flush() + + const insightsEvent = sendBatchEvents.mock.calls[0]?.[0][0]?.events[0] + expect(insightsEvent).toEqual(expect.objectContaining({ anonymousId: profileFixture.id })) + }) + + it('prefers an explicit anonymousId over the profile-derived one', async () => { + const core = createCore({ + defaults: { consent: true, profile: profileFixture }, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackNodeView({ ...BASE_PAYLOAD, anonymousId: 'anon-override' }) + await core.flush() + + const insightsEvent = sendBatchEvents.mock.calls[0]?.[0][0]?.events[0] + expect(insightsEvent).toEqual(expect.objectContaining({ anonymousId: 'anon-override' })) + }) + + it('reports a consent-blocked event and does not send when consent is missing', async () => { + const onEventBlocked = rs.fn() + const core = createCore({ onEventBlocked, defaults: { profile: profileFixture } }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + const blockedEvents: Array = [] + const subscription = core.states.blockedEventStream.subscribe((event) => { + blockedEvents.push(event) + }) + + await core.trackNodeView(BASE_PAYLOAD) + await core.flush() + + expect(sendBatchEvents).not.toHaveBeenCalled() + expect(onEventBlocked).toHaveBeenCalledWith( + expect.objectContaining({ method: 'trackNodeView', reason: 'consent' }), + ) + expect(blockedEvents.at(-1)).toEqual( + expect.objectContaining({ method: 'trackNodeView', reason: 'consent' }), + ) + + subscription.unsubscribe() + }) + + it('is unblocked when the allow-list opts into exo_node_view', async () => { + const core = createCore({ + allowedEventTypes: ['exo_node_view'], + defaults: { profile: profileFixture }, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackNodeView(BASE_PAYLOAD) + await core.flush() + + expect(sendBatchEvents).toHaveBeenCalledTimes(1) + }) + + it('includes parentExperienceId in the emitted event when supplied', async () => { + const core = createCore({ + defaults: { consent: true, profile: profileFixture }, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackNodeView({ ...BASE_PAYLOAD, parentExperienceId: 'parent-exp' }) + await core.flush() + + const insightsEvent = sendBatchEvents.mock.calls[0]?.[0][0]?.events[0] + expect(insightsEvent).toEqual(expect.objectContaining({ parentExperienceId: 'parent-exp' })) + }) +}) diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index 38310a669..f23a7cf64 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -3,7 +3,9 @@ import type { InsightsApiClientRequestOptions, } from '@contentful/optimization-api-client' import type { + ExperienceEventArray, Json, + OptimizationData, Profile, SelectedOptimizationArray, } from '@contentful/optimization-api-client/api-schemas' @@ -53,6 +55,7 @@ import { signalFns, toObservable, } from './signals' +import { applyOptimizationDataToSignals } from './state/applyOptimizationDataToSignals' import { resolveStatefulDefaults, type StatefulDefaults } from './StatefulDefaults' const coreLogger = createScopedLogger('CoreStateful') @@ -126,6 +129,25 @@ const resolveQueuePolicy = (policy: QueuePolicy | undefined): ResolvedQueuePolic onOfflineDrop: policy?.onOfflineDrop, }) +/** + * Payload assembled for a personalized XDA view-synthesis request. + * + * @remarks + * Produced by {@link CoreStateful.getPersonalizationRequest}. Callers pass this + * as `extensions.personalization` on a delivery-client + * `view.getExperienceWithOverrides(...)` call. `events` is Segment-style — + * `page` / `track` / `identify` / `screen` / `alias` / `group` / `view` — and + * is empty when nothing has queued. + * + * @public + */ +export interface PersonalizationRequest { + /** Anonymous profile id the SDK is holding, if any. */ + profileId?: string + /** Queued personalization events drained from the Experience queue. */ + events: ExperienceEventArray +} + /** * Combined observable state exposed by the stateful core. * @@ -432,6 +454,64 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController } } + /** + * Assemble the payload for a personalized XDA view-synthesis request. + * + * @remarks + * Reads the current anonymous profile id and drains any queued Experience + * events through the shared consent gate. Events blocked by consent remain + * queued so they can be sent once consent is granted. Returns + * `{ events: [] }` when nothing is queued and no profile is held. + * + * @returns The `profileId` and event batch to pass as + * `extensions.personalization`. + * + * @example + * ```ts + * const personalization = optimization.getPersonalizationRequest() + * const view = await deliveryClient.view.getExperienceWithOverrides( + * spaceId, envId, experienceId, + * { extensions: { personalization, sourceMap: {} } }, + * { headers: { 'x-contentful-extensions-sourcemap': 'true' } }, + * ) + * optimization.ingestPersonalizationResponse(view.extensions?.personalization) + * ``` + * + * @public + */ + getPersonalizationRequest(): PersonalizationRequest { + const events = this.drainQueuedExperienceEvents('getPersonalizationRequest') + const profileId = profileSignal.value?.id + + return profileId === undefined ? { events } : { profileId, events } + } + + /** + * Apply a personalization response back into SDK state. + * + * @remarks + * Same terminal write path already used inside + * `sendExperienceEventWithResult`: updates {@link CoreStates.profile}, + * {@link CoreStates.selectedOptimizations}, and + * {@link CoreStates.experienceRequestState}. Safe to call repeatedly with + * the same payload — signal writes are equality-gated. + * + * @param personalization - The `data` payload from XDA's + * `extensions.personalization`. `undefined` is a no-op (used when the + * response carries no personalization). + * + * @public + */ + ingestPersonalizationResponse(personalization?: OptimizationData): void { + if (personalization === undefined) return + + void applyOptimizationDataToSignals(personalization, this.interceptors.state).catch( + (error: unknown) => { + logger.warn('Failed to ingest personalization response', String(error)) + }, + ) + } + destroy(): void { if (this.destroyed) return diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index e2e26eb10..907470bd7 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -1,5 +1,6 @@ import type { ChangeArray, + ExperienceEventArray, ExperienceEvent as ExperienceEventPayload, InsightsEvent as InsightsEventPayload, Json, @@ -22,6 +23,8 @@ import type { FlagViewBuilderArgs, HoverBuilderArgs, IdentifyBuilderArgs, + NodeViewBuilderArgs, + NodeViewTrackingArgs, PageViewBuilderArgs, ScreenViewBuilderArgs, TrackBuilderArgs, @@ -270,6 +273,37 @@ abstract class CoreStatefulEventEmitter ) } + /** + * Track an XDA graph-node view through Insights. + * + * @param payload - Node view builder arguments. When `anonymousId` is + * omitted, the emitter derives it from the active profile. + * @returns A promise that resolves when processing completes. + * + * @example + * ```ts + * await core.trackNodeView({ + * entityId: 'exp-1', + * entityKind: 'Experience', + * variantId: 'variant-a', + * variantIndex: 1, + * optimizationId: 'opt-1', + * 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), + ) + } + /** * Track a feature flag view through Insights. * @@ -305,8 +339,7 @@ abstract class CoreStatefulEventEmitter event: ExperienceEventPayload, optimizationContext?: EventOptimizationContext, ): Promise { - if (!this.hasConsent(method)) { - this.onBlockedByConsent(method, args) + if (!this.guardExperienceEventConsent(method, args)) { return { accepted: false } } @@ -316,6 +349,30 @@ abstract class CoreStatefulEventEmitter return { accepted: true, data } } + /** + * Drain queued Experience events through the shared consent gate. + * + * @remarks + * Events blocked by consent remain queued — the drain is a no-op in that case + * so the events can be sent once consent is granted. + * + * @internal + */ + protected drainQueuedExperienceEvents(method: string): ExperienceEventArray { + if (!this.guardExperienceEventConsent(method, [])) return [] + + return this.experienceQueue.drainQueuedEvents() + } + + private guardExperienceEventConsent(method: string, args: readonly unknown[]): boolean { + if (!this.hasConsent(method)) { + this.onBlockedByConsent(method, args) + return false + } + + return true + } + protected async sendInsightsEvent( method: string, args: readonly unknown[], diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.ts index f9a214c6c..05918039a 100644 --- a/packages/universal/core-sdk/src/CoreStatelessRequest.ts +++ b/packages/universal/core-sdk/src/CoreStatelessRequest.ts @@ -17,6 +17,8 @@ import type { FlagViewBuilderArgs, HoverBuilderArgs, IdentifyBuilderArgs, + NodeViewBuilderArgs, + NodeViewTrackingArgs, PageViewBuilderArgs, ScreenViewBuilderArgs, TrackBuilderArgs, @@ -33,6 +35,8 @@ const TRACK_HOVER_PROFILE_ERROR = 'CoreStatelessRequest.trackHover() requires a request-bound profile id for Insights delivery.' const TRACK_FLAG_VIEW_PROFILE_ERROR = 'CoreStatelessRequest.trackFlagView() requires a request-bound profile id for Insights delivery.' +const TRACK_NODE_VIEW_PROFILE_ERROR = + 'CoreStatelessRequest.trackNodeView() requires a request-bound profile id for Insights delivery.' const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = 'CoreStatelessRequest.trackView() requires a request-bound profile id when `payload.sticky` is not `true`.' const STICKY_TRACK_VIEW_PROFILE_ERROR = @@ -268,6 +272,19 @@ export class CoreStatelessRequest { ) } + async trackNodeView(payload: StatelessInsightsPayload): Promise { + if (!this.hasConsent('exo_node_view')) { + this.reportBlockedEvent('trackNodeView', [payload]) + return + } + + const profile = requireInsightsProfile(this.currentProfile, TRACK_NODE_VIEW_PROFILE_ERROR) + const anonymousId = payload.anonymousId ?? profile.id + const builderArgs: NodeViewBuilderArgs = this.withEventContext({ ...payload, anonymousId }) + + await this.sendAllowedInsightsEvent(this.core.eventBuilder.buildNodeView(builderArgs), profile) + } + async trackFlagView(payload: StatelessInsightsPayload): Promise { if (!this.hasConsent('flag', 'component')) { this.reportBlockedEvent('trackFlagView', [payload]) diff --git a/packages/universal/core-sdk/src/consent/ConsentPolicy.ts b/packages/universal/core-sdk/src/consent/ConsentPolicy.ts index 0ecff80af..e79202685 100644 --- a/packages/universal/core-sdk/src/consent/ConsentPolicy.ts +++ b/packages/universal/core-sdk/src/consent/ConsentPolicy.ts @@ -23,6 +23,10 @@ export const hasEventConsent = ( } if (method === 'trackClick') return allowedEventTypes.includes('component_click') if (method === 'trackHover') return allowedEventTypes.includes('component_hover') + if (method === 'trackNodeView') return allowedEventTypes.includes('exo_node_view') + if (method === 'getPersonalizationRequest') { + return UNLOCKING_EVENT_TYPES.some((type) => allowedEventTypes.includes(type)) + } return allowedEventTypes.some((eventType) => eventType === method) } diff --git a/packages/universal/core-sdk/src/events/EventBuilder.test.ts b/packages/universal/core-sdk/src/events/EventBuilder.test.ts index b99c704bc..900446890 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.test.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.test.ts @@ -135,3 +135,55 @@ describe('EventBuilder entry interactions', () => { expect(click).not.toHaveProperty('optimizationContextId') }) }) + +describe('EventBuilder.buildNodeView', () => { + const baseArgs = { + anonymousId: 'anon-1', + entityId: 'entity-1', + entityKind: 'Experience' as const, + variantId: 'variant-a', + variantIndex: 1, + optimizationId: 'opt-1', + viewId: 'view-1', + viewDurationMs: 1_000, + } + + it('builds an exo_node_view event carrying all node-view fields', () => { + const event = builder.buildNodeView(baseArgs) + + expect(event.type).toBe('exo_node_view') + expect(event.anonymousId).toBe('anon-1') + expect(event.entityId).toBe('entity-1') + expect(event.entityKind).toBe('Experience') + expect(event.variantId).toBe('variant-a') + expect(event.variantIndex).toBe(1) + expect(event.optimizationId).toBe('opt-1') + expect(event.viewId).toBe('view-1') + expect(event.viewDurationMs).toBe(1_000) + }) + + it('includes parentExperienceId when supplied', () => { + const event = builder.buildNodeView({ + ...baseArgs, + parentExperienceId: 'parent-exp-1', + }) + + expect(event.parentExperienceId).toBe('parent-exp-1') + }) + + it('accepts entityKind = Fragment', () => { + const event = builder.buildNodeView({ ...baseArgs, entityKind: 'Fragment' }) + + expect(event.entityKind).toBe('Fragment') + }) + + it('rejects unknown entityKind values at parse time', () => { + expect(() => + builder.buildNodeView({ + ...baseArgs, + // @ts-expect-error — validate schema rejects unsupported kinds + entityKind: 'Component', + }), + ).toThrow() + }) +}) diff --git a/packages/universal/core-sdk/src/events/EventBuilder.ts b/packages/universal/core-sdk/src/events/EventBuilder.ts index 640e0858d..24db69520 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, @@ -278,6 +279,40 @@ 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')]), + variantId: z.string(), + variantIndex: z.number(), + optimizationId: z.string(), + viewId: z.string(), + viewDurationMs: z.number(), + parentExperienceId: z.optional(z.string()), +}) + +/** + * Arguments for constructing `exo_node_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. * @@ -737,6 +772,58 @@ class EventBuilder { properties, } } + + /** + * Builds an `exo_node_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({ + * anonymousId: 'anon-id', + * entityId: 'experience-sys-id', + * entityKind: 'Experience', + * variantId: 'variant-a', + * variantIndex: 1, + * optimizationId: 'optimization-id', + * viewId: crypto.randomUUID(), + * viewDurationMs: 1_000, + * }) + * ``` + * + * @public + */ + buildNodeView(args: NodeViewBuilderArgs): NodeViewEvent { + const { + anonymousId, + entityId, + entityKind, + variantId, + variantIndex, + optimizationId, + viewId, + viewDurationMs, + parentExperienceId, + ...universal + } = parseWithFriendlyError(NodeViewBuilderArgs, args) + + return { + ...this.buildUniversalEventProperties(universal), + anonymousId, + type: 'exo_node_view', + entityId, + entityKind, + variantId, + variantIndex, + optimizationId, + viewId, + viewDurationMs, + parentExperienceId, + } + } } export default EventBuilder diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts index 8cb663cad..bc90bbc21 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts @@ -108,6 +108,27 @@ export class ExperienceQueue { this.flushRuntime.reset() } + /** + * Remove and return all currently queued Experience events in oldest-first order. + * + * @remarks + * Empties the offline queue and resets the flush retry state. Intended for + * callers that will submit the events through a different transport (e.g. the + * personalization request handed to the delivery client). No consent check — + * callers must gate on consent before invoking this. + * + * @internal + */ + drainQueuedEvents(): ExperienceEventArray { + if (this.queuedExperienceEvents.size === 0) return [] + + const drained = Array.from(this.queuedExperienceEvents) + this.queuedExperienceEvents.clear() + this.flushRuntime.reset() + + return drained + } + async send( event: ExperienceEventPayload, optimizationContext?: EventOptimizationContext, diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 69ded0bbe..452181544 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -112,6 +112,16 @@ "default": "./dist/router/tanstack-router.cjs" } }, + "./experiences-adapter": { + "import": { + "types": "./dist/experiences-adapter.d.mts", + "default": "./dist/experiences-adapter.mjs" + }, + "require": { + "types": "./dist/experiences-adapter.d.cts", + "default": "./dist/experiences-adapter.cjs" + } + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/web/frameworks/react-web-sdk/rslib.config.ts b/packages/web/frameworks/react-web-sdk/rslib.config.ts index 5a49b5a24..6c872ec5e 100644 --- a/packages/web/frameworks/react-web-sdk/rslib.config.ts +++ b/packages/web/frameworks/react-web-sdk/rslib.config.ts @@ -25,6 +25,7 @@ const CLIENT_DIRECTIVE = "'use client';" const reactClientEntries = { index: './src/index.ts', + 'experiences-adapter': './src/experiences-adapter/index.ts', 'router/next-app': './src/router/next-app.tsx', 'router/next-pages': './src/router/next-pages.tsx', 'router/react-router': './src/router/react-router.tsx', diff --git a/packages/web/frameworks/react-web-sdk/src/experiences-adapter/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/experiences-adapter/index.test.tsx new file mode 100644 index 000000000..0c36e9025 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/experiences-adapter/index.test.tsx @@ -0,0 +1,267 @@ +import ContentfulOptimization from '@contentful/optimization-web' +import { + SourceMap, + type SourceMapLayer, + type SourceMapNode, + type SourceMapVariant, +} from '@contentful/optimization-web/api-schemas' +import { describe, expect, it } from '@rstest/core' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { createOptimizationSdk } from '../test/sdkTestUtils' +import { getExperiencesAdapter, type ExperiencesOptimizationAdapter } from './index' + +function makeSourceMap(input: { + variants?: SourceMapVariant[] + layers?: SourceMapLayer[] + nodes?: Record +}): SourceMap { + return SourceMap.parse({ + version: 1, + variants: input.variants ?? [], + spaces: [], + environments: [], + locales: [], + entries: [], + assets: [], + layers: input.layers ?? [], + dataAssemblies: [], + nodes: input.nodes ?? {}, + }) +} + +function nodeEntry(layers: number[], scope: number): SourceMapNode { + return { layers, scope, contentProperties: [] } +} + +function makePersonalizedSourceMap(): SourceMap { + return makeSourceMap({ + variants: [ + { + type: 'personalization', + id: 'variant-entry-id', + experienceId: 'exp-id', + optimizationId: 'opt-id', + variantId: 'variant-a', + variantIndex: 1, + }, + ], + layers: [{ kind: 'Experience', id: 'exp-id', variants: [0] }], + nodes: { 'node-1': nodeEntry([0], 0) }, + }) +} + +function makeOptimization(): ContentfulOptimization { + const sdk = createOptimizationSdk({ hasConsent: (name) => name.startsWith('track') }) + if (!(sdk instanceof ContentfulOptimization)) { + throw new Error('Expected optimization test double to use the ContentfulOptimization prototype') + } + return sdk +} + +async function mountProbe(element: React.ReactElement): Promise<{ + container: HTMLDivElement + unmount: () => Promise +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + await Promise.resolve() + root.render(element) + }) + return { + container, + async unmount() { + await act(async () => { + await Promise.resolve() + root.unmount() + }) + container.remove() + }, + } +} + +async function captureBinding( + adapter: ExperiencesOptimizationAdapter, + nodeId: string, + sourceMap: SourceMap | undefined, +): Promise<{ + element: HTMLDivElement + resolved: ReturnType['resolved'] + unmount: () => Promise +}> { + const state: { + resolved: ReturnType['resolved'] | null + element: HTMLDivElement | null + } = { resolved: null, element: null } + + function Probe(): React.JSX.Element { + const { ref, resolved } = adapter.useNodeBinding(nodeId, sourceMap) + state.resolved = resolved + return ( +
{ + ref(element) + state.element = element + }} + /> + ) + } + + const view = await mountProbe() + + const { element } = state + if (element === null) { + await view.unmount() + throw new Error('Expected element to be captured') + } + + return { element, resolved: state.resolved, unmount: view.unmount } +} + +describe('experiences-adapter', () => { + describe('useNodeBinding', () => { + it('stamps every data-ctfl-* attribute for an attributable node', async () => { + const optimization = makeOptimization() + const adapter = getExperiencesAdapter(optimization) + const sourceMap = makePersonalizedSourceMap() + + const { element, resolved, unmount } = await captureBinding(adapter, 'node-1', sourceMap) + + expect(resolved).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'opt-id', + variantId: 'variant-a', + variantIndex: 1, + parentExperienceId: undefined, + }) + + expect(element.getAttribute('data-ctfl-node-id')).toBe('node-1') + expect(element.getAttribute('data-ctfl-entity-id')).toBe('exp-id') + expect(element.getAttribute('data-ctfl-entity-kind')).toBe('Experience') + expect(element.getAttribute('data-ctfl-optimization-id')).toBe('opt-id') + expect(element.getAttribute('data-ctfl-variant')).toBe('variant-a') + expect(element.getAttribute('data-ctfl-variant-index')).toBe('1') + expect(element.getAttribute('data-ctfl-parent-experience-id')).toBeNull() + + await unmount() + }) + + it('stamps parentExperienceId when the resolver returns one', async () => { + const sourceMap = makeSourceMap({ + variants: [ + { + type: 'personalization', + id: 'variant-entry-id', + optimizationId: 'opt-id', + variantId: 'variant-a', + variantIndex: 2, + }, + ], + layers: [ + { kind: 'Fragment', id: 'frag-id', variants: [0] }, + { kind: 'Experience', id: 'parent-exp-id', variants: [] }, + ], + nodes: { 'node-1': nodeEntry([0, 1], 0) }, + }) + + const optimization = makeOptimization() + const adapter = getExperiencesAdapter(optimization) + const { element, resolved, unmount } = await captureBinding(adapter, 'node-1', sourceMap) + + expect(resolved).toMatchObject({ + entityKind: 'Fragment', + parentExperienceId: 'parent-exp-id', + }) + expect(element.getAttribute('data-ctfl-parent-experience-id')).toBe('parent-exp-id') + + await unmount() + }) + + it('returns resolved === null and does not stamp when node is non-attributable', async () => { + const sourceMap = makeSourceMap({ + variants: [], + layers: [{ kind: 'Slot', id: 'slot-id' }], + nodes: { 'node-1': nodeEntry([0], 0) }, + }) + + const optimization = makeOptimization() + const adapter = getExperiencesAdapter(optimization) + const { element, resolved, unmount } = await captureBinding(adapter, 'node-1', sourceMap) + + expect(resolved).toBeNull() + expect(element.getAttribute('data-ctfl-node-id')).toBeNull() + expect(element.getAttribute('data-ctfl-entity-id')).toBeNull() + + await unmount() + }) + + it('returns resolved === null when the sourceMap is undefined', async () => { + const optimization = makeOptimization() + const adapter = getExperiencesAdapter(optimization) + const { element, resolved, unmount } = await captureBinding(adapter, 'node-1', undefined) + + expect(resolved).toBeNull() + expect(element.getAttribute('data-ctfl-node-id')).toBeNull() + + await unmount() + }) + + it('renders on the server without stamping (ref callback is client-only)', () => { + const optimization = makeOptimization() + const adapter = getExperiencesAdapter(optimization) + const sourceMap = makePersonalizedSourceMap() + + function Probe(): React.JSX.Element { + const { ref } = adapter.useNodeBinding('node-1', sourceMap) + return
+ } + + const html = renderToString() + expect(html).toContain('data-ctfl-node-id="node-1"') + }) + }) + + describe('attachInteractionRuntime', () => { + it('returns a cleanup that disables only what was enabled', () => { + const optimization = makeOptimization() + const adapter = getExperiencesAdapter(optimization) + + // Cleanup is a function even when we ask for nothing. + const noop = adapter.attachInteractionRuntime({ + views: false, + clicks: false, + hovers: false, + }) + expect(typeof noop).toBe('function') + noop() + + // Enabling a subset should not throw; cleanup must be idempotent. + const cleanup = adapter.attachInteractionRuntime({ + views: true, + clicks: false, + hovers: true, + }) + expect(typeof cleanup).toBe('function') + cleanup() + cleanup() + }) + + it('shares one NodeInteractionRuntime per optimization instance', () => { + const optimization = makeOptimization() + const a = getExperiencesAdapter(optimization) + const b = getExperiencesAdapter(optimization) + + // Two adapter objects, but they must coordinate through one runtime — + // verified by not throwing when a's cleanup runs after b enabled a + // disjoint interaction on the same underlying runtime. + const cleanupA = a.attachInteractionRuntime({ views: true, clicks: false, hovers: false }) + const cleanupB = b.attachInteractionRuntime({ views: false, clicks: true, hovers: false }) + cleanupA() + cleanupB() + }) + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/experiences-adapter/index.ts b/packages/web/frameworks/react-web-sdk/src/experiences-adapter/index.ts new file mode 100644 index 000000000..c57ce37e9 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/experiences-adapter/index.ts @@ -0,0 +1,151 @@ +'use client' + +import type ContentfulOptimization from '@contentful/optimization-web' +import { + NodeInteractionRuntime, + resolveNodeViewPayload, + type NodeInteraction, + type ResolvedNodeMetadata, +} from '@contentful/optimization-web' +import type { SourceMap } from '@contentful/optimization-web/api-schemas' +import { useCallback, useMemo, useRef } from 'react' + +/** + * Adapter published on the `./experiences-adapter` subpath so that + * `@contentful/experiences-react` can hook up per-node instrumentation without + * importing SDK internals directly. + * + * @public + */ +export interface ExperiencesOptimizationAdapter { + useNodeBinding: ( + nodeId: string, + sourceMap: SourceMap | undefined, + ) => { + ref: (element: HTMLElement | null) => void + resolved: ResolvedNodeMetadata | null + } + attachInteractionRuntime: (opts: { + views: boolean + clicks: boolean + hovers: boolean + }) => () => void +} + +/** + * Attributes stamped by the ref-callback. Kept as an ordered pair-list so + * the write loop is a single pass and the vocabulary matches + * `resolveNodeDataset` on the runtime side (see + * `packages/web/web-sdk/src/entry-tracking/resolveNodeViewArgs.ts`). + */ +const NODE_ATTRIBUTES = [ + 'data-ctfl-node-id', + 'data-ctfl-entity-id', + 'data-ctfl-entity-kind', + 'data-ctfl-optimization-id', + 'data-ctfl-variant', + 'data-ctfl-variant-index', + 'data-ctfl-parent-experience-id', +] as const + +function stampNodeAttributes( + element: HTMLElement, + nodeId: string, + resolved: ResolvedNodeMetadata, +): void { + element.setAttribute('data-ctfl-node-id', nodeId) + element.setAttribute('data-ctfl-entity-id', resolved.entityId) + element.setAttribute('data-ctfl-entity-kind', resolved.entityKind) + element.setAttribute('data-ctfl-optimization-id', resolved.optimizationId) + element.setAttribute('data-ctfl-variant', resolved.variantId) + element.setAttribute('data-ctfl-variant-index', String(resolved.variantIndex)) + if (resolved.parentExperienceId !== undefined) { + element.setAttribute('data-ctfl-parent-experience-id', resolved.parentExperienceId) + } else { + element.removeAttribute('data-ctfl-parent-experience-id') + } +} + +function clearNodeAttributes(element: HTMLElement): void { + NODE_ATTRIBUTES.forEach((attribute) => { + element.removeAttribute(attribute) + }) +} + +const runtimeByOptimization = new WeakMap() + +function ensureRuntime(optimization: ContentfulOptimization): NodeInteractionRuntime { + const cached = runtimeByOptimization.get(optimization) + if (cached !== undefined) return cached + + const runtime = new NodeInteractionRuntime(optimization) + runtimeByOptimization.set(optimization, runtime) + return runtime +} + +const NODE_INTERACTIONS: readonly NodeInteraction[] = ['views', 'clicks', 'hovers'] + +/** + * Build the adapter surface consumed by `@contentful/experiences-react`. + * + * @remarks + * The adapter is a pure function of the optimization instance — it holds no + * closure state beyond a per-instance {@link NodeInteractionRuntime} cached on + * a module-level `WeakMap`. Calling `getExperiencesAdapter` twice with the + * same instance returns two adapter objects that share the same runtime, so + * multiple mounted renderers coordinate through a single observer set. + * + * @public + */ +export function getExperiencesAdapter( + optimization: ContentfulOptimization, +): ExperiencesOptimizationAdapter { + const useNodeBinding: ExperiencesOptimizationAdapter['useNodeBinding'] = (nodeId, sourceMap) => { + const resolved = useMemo( + () => (sourceMap === undefined ? null : resolveNodeViewPayload(nodeId, sourceMap)), + [nodeId, sourceMap], + ) + + const lastElement = useRef(null) + + const ref = useCallback( + (element: HTMLElement | null): void => { + const { current: previous } = lastElement + if (previous && previous !== element) { + clearNodeAttributes(previous) + } + lastElement.current = element + if (element === null) return + if (resolved === null) { + clearNodeAttributes(element) + return + } + stampNodeAttributes(element, nodeId, resolved) + }, + [nodeId, resolved], + ) + + return { ref, resolved } + } + + const attachInteractionRuntime: ExperiencesOptimizationAdapter['attachInteractionRuntime'] = ( + opts, + ) => { + const runtime = ensureRuntime(optimization) + const enabled: NodeInteraction[] = [] + NODE_INTERACTIONS.forEach((interaction) => { + if (opts[interaction]) { + runtime.tracking.enable(interaction) + enabled.push(interaction) + } + }) + + return () => { + enabled.forEach((interaction) => { + runtime.tracking.disable(interaction) + }) + } + } + + return { useNodeBinding, attachInteractionRuntime } +} diff --git a/packages/web/web-sdk/src/constants.ts b/packages/web/web-sdk/src/constants.ts index c8ed4a9d4..230d3ff87 100644 --- a/packages/web/web-sdk/src/constants.ts +++ b/packages/web/web-sdk/src/constants.ts @@ -65,6 +65,21 @@ export const ENTRY_ID_ATTRIBUTE = 'data-ctfl-entry-id' */ export const ENTRY_SELECTOR = `[${ENTRY_ID_ATTRIBUTE}]` +/** + * Attribute used to identify tracked graph-node elements stamped by the + * `experiences-adapter` node binding. + * + * @public + */ +export const NODE_ID_ATTRIBUTE = 'data-ctfl-node-id' + +/** + * Selector used to locate tracked graph-node elements in the DOM. + * + * @public + */ +export const NODE_SELECTOR = `[${NODE_ID_ATTRIBUTE}]` + /** * Flag indicating whether the current environment can safely add DOM * event listeners. diff --git a/packages/web/web-sdk/src/entry-tracking/NodeInteractionDetector.ts b/packages/web/web-sdk/src/entry-tracking/NodeInteractionDetector.ts new file mode 100644 index 000000000..57df589c3 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/NodeInteractionDetector.ts @@ -0,0 +1,13 @@ +/** + * Detector contract implemented by node-keyed interaction strategies + * (view / click / hover). The `NodeInteractionRuntime` coordinates element + * discovery and observation state; detectors expose an `observe`/`unobserve` + * surface plus a `disconnect` teardown hook. + * + * @internal + */ +export interface NodeInteractionDetector { + observe: (element: Element) => void + unobserve: (element: Element) => void + disconnect: () => void +} diff --git a/packages/web/web-sdk/src/entry-tracking/NodeInteractionRuntime.test.ts b/packages/web/web-sdk/src/entry-tracking/NodeInteractionRuntime.test.ts new file mode 100644 index 000000000..cda9e90ab --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/NodeInteractionRuntime.test.ts @@ -0,0 +1,172 @@ +import type { NodeInteractionDetector } from './NodeInteractionDetector' +import { NodeInteractionRuntime } from './NodeInteractionRuntime' +import * as clickDetectorModule from './events/click/createNodeClickDetector' +import * as hoverDetectorModule from './events/hover/createNodeHoverDetector' +import * as viewDetectorModule from './events/view/createNodeViewDetector' + +interface DetectorMocks extends NodeInteractionDetector { + observe: ReturnType + unobserve: ReturnType + disconnect: ReturnType +} + +const createDetectorMocks = (): DetectorMocks => ({ + observe: rs.fn(), + unobserve: rs.fn(), + disconnect: rs.fn(), +}) + +function createRuntime(hasConsent: (name: string) => boolean = () => true): { + runtime: NodeInteractionRuntime + viewDetector: DetectorMocks + clickDetector: DetectorMocks + hoverDetector: DetectorMocks +} { + const core = { + trackNodeView: rs.fn().mockResolvedValue(undefined), + trackClick: rs.fn().mockResolvedValue(undefined), + trackHover: rs.fn().mockResolvedValue(undefined), + hasConsent: rs.fn(hasConsent), + } + const viewDetector = createDetectorMocks() + const clickDetector = createDetectorMocks() + const hoverDetector = createDetectorMocks() + + rs.spyOn(viewDetectorModule, 'createNodeViewDetector').mockReturnValue(viewDetector) + rs.spyOn(clickDetectorModule, 'createNodeClickDetector').mockReturnValue(clickDetector) + rs.spyOn(hoverDetectorModule, 'createNodeHoverDetector').mockReturnValue(hoverDetector) + + return { + runtime: new NodeInteractionRuntime(core), + viewDetector, + clickDetector, + hoverDetector, + } +} + +function makeNodeElement(id = 'node-1'): HTMLElement { + const el = document.createElement('div') + el.dataset.ctflNodeId = id + return el +} + +describe('NodeInteractionRuntime', () => { + afterEach(() => { + rs.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('seeds existing node elements onto each newly enabled detector', () => { + const seeded = makeNodeElement('seeded') + document.body.append(seeded) + const { runtime, viewDetector, clickDetector, hoverDetector } = createRuntime() + + runtime.tracking.enable('views') + expect(viewDetector.observe).toHaveBeenCalledWith(seeded) + expect(clickDetector.observe).not.toHaveBeenCalled() + expect(hoverDetector.observe).not.toHaveBeenCalled() + + runtime.tracking.enable('clicks') + expect(clickDetector.observe).toHaveBeenCalledWith(seeded) + + runtime.tracking.enable('hovers') + expect(hoverDetector.observe).toHaveBeenCalledWith(seeded) + }) + + it('fans DOM insertions and removals out to running detectors', async () => { + const { runtime, viewDetector } = createRuntime() + + runtime.tracking.enable('views') + viewDetector.observe.mockClear() + + const el = makeNodeElement('dynamic') + document.body.append(el) + + await Promise.resolve() + await Promise.resolve() + expect(viewDetector.observe).toHaveBeenCalledWith(el) + + document.body.removeChild(el) + await Promise.resolve() + await Promise.resolve() + expect(viewDetector.unobserve).toHaveBeenCalledWith(el) + }) + + it('disables globally by clearing the auto-track flag', () => { + const seeded = makeNodeElement() + document.body.append(seeded) + const { runtime, viewDetector } = createRuntime() + + runtime.tracking.enable('views') + runtime.tracking.disable('views') + + expect(viewDetector.unobserve).toHaveBeenCalledWith(seeded) + }) + + it('runs a force-enabled element even when global auto-tracking is off', () => { + const el = makeNodeElement() + const { runtime, clickDetector } = createRuntime() + + runtime.tracking.enableElement('clicks', el) + + expect(clickDetector.observe).toHaveBeenCalledWith(el) + }) + + it('stops force-enabled-only interactions after clearing the final override', () => { + const el = makeNodeElement() + const { runtime, clickDetector } = createRuntime() + + runtime.tracking.enableElement('clicks', el) + runtime.tracking.clearElement('clicks', el) + + expect(clickDetector.unobserve).toHaveBeenCalledWith(el) + }) + + it('skips force-disabled elements even when auto-tracking is on', () => { + const seeded = makeNodeElement() + document.body.append(seeded) + const { runtime, viewDetector } = createRuntime() + + runtime.tracking.disableElement('views', seeded) + runtime.tracking.enable('views') + + expect(viewDetector.observe).not.toHaveBeenCalled() + }) + + it('waits for consent before starting a globally-enabled interaction', () => { + let allowed = false + const seeded = makeNodeElement() + document.body.append(seeded) + const { runtime, viewDetector } = createRuntime((name) => name !== 'trackNodeView' || allowed) + + runtime.tracking.enable('views') + expect(viewDetector.observe).not.toHaveBeenCalled() + + allowed = true + runtime.syncAutoTrackedNodeInteractions() + expect(viewDetector.observe).toHaveBeenCalledWith(seeded) + }) + + it('reset stops running interactions and clears element overrides', () => { + const el = makeNodeElement() + const { runtime, clickDetector } = createRuntime() + + runtime.tracking.enableElement('clicks', el) + runtime.reset() + runtime.tracking.clearElement('clicks', el) + + expect(clickDetector.unobserve).toHaveBeenCalledWith(el) + }) + + it('destroy disconnects the mutation observer and detectors', () => { + const { runtime, viewDetector, clickDetector, hoverDetector } = createRuntime() + runtime.tracking.enable('views') + + runtime.destroy() + + expect(viewDetector.disconnect).toHaveBeenCalledTimes(1) + expect(clickDetector.disconnect).toHaveBeenCalledTimes(1) + expect(hoverDetector.disconnect).toHaveBeenCalledTimes(1) + expect(Reflect.get(runtime, 'nodeElementObserver')).toBeUndefined() + }) +}) diff --git a/packages/web/web-sdk/src/entry-tracking/NodeInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/NodeInteractionRuntime.ts new file mode 100644 index 000000000..d925604b5 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/NodeInteractionRuntime.ts @@ -0,0 +1,413 @@ +import { HAS_MUTATION_OBSERVER, NODE_ID_ATTRIBUTE, NODE_SELECTOR } from '../constants' +import { + createNodeClickDetector, + type NodeClickTrackingCore, +} from './events/click/createNodeClickDetector' +import { + createNodeHoverDetector, + type NodeHoverTrackingCore, +} from './events/hover/createNodeHoverDetector' +import type { ElementHoverObserverOptions } from './events/hover/element-hover-observer-support' +import { + createNodeViewDetector, + type NodeViewTrackingCore, +} from './events/view/createNodeViewDetector' +import type { ElementViewObserverOptions } from './events/view/element-view-observer-support' +import type { NodeInteractionDetector } from './NodeInteractionDetector' + +/** + * Node-keyed interaction the runtime coordinates. + * + * @public + */ +export type NodeInteraction = 'views' | 'clicks' | 'hovers' + +const NODE_INTERACTIONS: readonly NodeInteraction[] = ['clicks', 'views', 'hovers'] + +const NODE_INTERACTION_CONSENT_METHODS: Readonly> = { + views: 'trackNodeView', + clicks: 'trackClick', + hovers: 'trackHover', +} + +/** + * Core shape required by {@link NodeInteractionRuntime}. + * + * @public + */ +export type NodeInteractionRuntimeCore = NodeViewTrackingCore & + NodeClickTrackingCore & + NodeHoverTrackingCore & { + hasConsent: (name: string) => boolean + } + +/** + * Options forwarded to individual node-interaction detectors. + * + * @public + */ +export interface NodeInteractionRuntimeOptions { + view?: ElementViewObserverOptions + hover?: ElementHoverObserverOptions +} + +/** + * Imperative API mirroring `EntryInteractionApi` but keyed on + * `data-ctfl-node-id` elements. + * + * @public + */ +export interface OptimizationTrackingApi { + enable: (interaction: NodeInteraction) => void + disable: (interaction: NodeInteraction) => void + enableElement: (interaction: NodeInteraction, element: Element) => void + disableElement: (interaction: NodeInteraction, element: Element) => void + clearElement: (interaction: NodeInteraction, element: Element) => void +} + +const isNode = (value: unknown): value is Node => + typeof Node !== 'undefined' && value instanceof Node + +const isElement = (node: Node): node is Element => + typeof Element !== 'undefined' && node instanceof Element + +const isDocumentFragment = (node: Node): node is DocumentFragment => + typeof DocumentFragment !== 'undefined' && node instanceof DocumentFragment + +function collectElements( + nodes: ReadonlySet, + requireConnected: boolean, + target: Set, +): void { + nodes.forEach((node) => { + if (isElement(node)) { + if (!requireConnected || node.isConnected) target.add(node) + node.querySelectorAll('*').forEach((element) => { + if (!requireConnected || element.isConnected) target.add(element) + }) + return + } + + if (!isDocumentFragment(node)) return + + node.querySelectorAll('*').forEach((element) => { + if (!requireConnected || element.isConnected) target.add(element) + }) + }) +} + +function collectNodeAttributeMutation( + record: MutationRecord, + addedNodes: Set, + removedNodes: Set, +): boolean { + if (record.type !== 'attributes' || record.attributeName !== NODE_ID_ATTRIBUTE) return false + + if (isNode(record.target)) { + removedNodes.add(record.target) + addedNodes.add(record.target) + } + + return true +} + +function collectChildListMutation( + record: MutationRecord, + addedNodes: Set, + removedNodes: Set, +): void { + record.addedNodes.forEach((node) => { + if (removedNodes.delete(node)) return + addedNodes.add(node) + }) + + record.removedNodes.forEach((node) => { + if (addedNodes.delete(node)) return + removedNodes.add(node) + }) +} + +interface ElementOverride { + enabled: boolean +} + +type OverrideMap = Record> + +type DetectorMap = Record + +/** + * Runtime coordinator for node-keyed interactions (views, clicks, hovers) + * used by `@contentful/optimization-react-web/experiences-adapter`. + * + * @remarks + * Discovers `data-ctfl-node-id` elements via a `MutationObserver`, and delegates + * observation to per-interaction detectors. Interactions activate only after + * global consent for the underlying event type is granted, matching the pattern + * established by {@link EntryInteractionRuntime}. + * + * @internal + */ +export class NodeInteractionRuntime { + private readonly core: NodeInteractionRuntimeCore + private readonly detectors: DetectorMap + private readonly nodeElements = new Set() + private readonly autoTrack: Record = { + views: false, + clicks: false, + hovers: false, + } + private readonly elementOverrides: OverrideMap = { + views: new Map(), + clicks: new Map(), + hovers: new Map(), + } + private readonly isInteractionRunning: Record = { + views: false, + clicks: false, + hovers: false, + } + private nodeElementObserver: MutationObserver | undefined = undefined + + public readonly tracking: OptimizationTrackingApi + + public constructor( + core: NodeInteractionRuntimeCore, + options: NodeInteractionRuntimeOptions = {}, + ) { + this.core = core + this.detectors = { + views: createNodeViewDetector(core, options.view), + clicks: createNodeClickDetector(core), + hovers: createNodeHoverDetector(core, options.hover), + } + + this.tracking = { + enable: (interaction): void => { + this.autoTrack[interaction] = true + this.reconcileInteraction(interaction) + }, + disable: (interaction): void => { + this.autoTrack[interaction] = false + this.reconcileInteraction(interaction) + }, + enableElement: (interaction, element): void => { + this.elementOverrides[interaction].set(element, { enabled: true }) + this.reconcileInteraction(interaction) + }, + disableElement: (interaction, element): void => { + this.elementOverrides[interaction].set(element, { enabled: false }) + this.reconcileInteraction(interaction) + }, + clearElement: (interaction, element): void => { + if (!this.elementOverrides[interaction].delete(element)) return + if (this.isInteractionRunning[interaction]) { + this.detectors[interaction].unobserve(element) + } + this.reconcileInteraction(interaction) + }, + } + } + + public reset(): void { + NODE_INTERACTIONS.forEach((interaction) => { + this.stopInteraction(interaction) + this.elementOverrides[interaction].clear() + }) + } + + public destroy(): void { + this.reset() + this.stopNodeElementObservation() + NODE_INTERACTIONS.forEach((interaction) => { + this.detectors[interaction].disconnect() + }) + } + + public syncAutoTrackedNodeInteractions(): void { + NODE_INTERACTIONS.forEach((interaction) => { + this.reconcileInteraction(interaction) + }) + } + + private reconcileInteraction(interaction: NodeInteraction): void { + const shouldRun = + this.isInteractionAllowed(interaction) && + (this.isAutoTrackingEnabled(interaction) || this.hasEnabledElementOverrides(interaction)) + + if (!shouldRun) { + if (this.isInteractionRunning[interaction]) this.stopInteraction(interaction) + return + } + + if (!this.isInteractionRunning[interaction]) this.startInteraction(interaction) + this.applyElementObservation(interaction) + } + + private isAutoTrackingEnabled(interaction: NodeInteraction): boolean { + if (interaction === 'views') return this.autoTrack.views + if (interaction === 'clicks') return this.autoTrack.clicks + return this.autoTrack.hovers + } + + private startInteraction(interaction: NodeInteraction): void { + this.ensureNodeElementObservation() + this.seedInitialNodeElements() + this.isInteractionRunning[interaction] = true + } + + private stopInteraction(interaction: NodeInteraction): void { + if (!this.isInteractionRunning[interaction]) return + + this.nodeElements.forEach((element) => { + this.detectors[interaction].unobserve(element) + }) + this.elementOverrides[interaction].forEach((_, element) => { + this.detectors[interaction].unobserve(element) + }) + this.isInteractionRunning[interaction] = false + + this.maybeStopNodeElementObservation() + } + + private applyElementObservation(interaction: NodeInteraction): void { + if (!this.isInteractionRunning[interaction]) return + + const detector = this.getDetector(interaction) + const overrides = this.getElementOverrides(interaction) + const autoTrack = this.isAutoTrackingEnabled(interaction) + + this.nodeElements.forEach((element) => { + const override = overrides.get(element) + if (override?.enabled === false) { + detector.unobserve(element) + return + } + if (override?.enabled === true || autoTrack) { + detector.observe(element) + } else { + detector.unobserve(element) + } + }) + + overrides.forEach((override, element) => { + if (this.nodeElements.has(element)) return + if (override.enabled) detector.observe(element) + else detector.unobserve(element) + }) + } + + private getDetector(interaction: NodeInteraction): NodeInteractionDetector { + return this.detectors[interaction] + } + + private getElementOverrides(interaction: NodeInteraction): Map { + return this.elementOverrides[interaction] + } + + private hasEnabledElementOverrides(interaction: NodeInteraction): boolean { + for (const override of this.elementOverrides[interaction].values()) { + if (override.enabled) return true + } + return false + } + + private isInteractionAllowed(interaction: NodeInteraction): boolean { + return this.core.hasConsent(NODE_INTERACTION_CONSENT_METHODS[interaction]) + } + + private ensureNodeElementObservation(): void { + if (this.nodeElementObserver) return + if (!HAS_MUTATION_OBSERVER || typeof document === 'undefined') return + + this.nodeElementObserver = new MutationObserver((records) => { + this.processNodeElementRecords(records) + }) + + this.nodeElementObserver.observe(document, { + attributeFilter: [NODE_ID_ATTRIBUTE], + attributes: true, + childList: true, + subtree: true, + }) + } + + private seedInitialNodeElements(): void { + if (typeof document === 'undefined') return + + document.querySelectorAll(NODE_SELECTOR).forEach((element) => { + if (this.nodeElements.has(element)) return + + this.nodeElements.add(element) + this.notifyNodeElementAdded(element) + }) + } + + private processNodeElementRecords(records: readonly MutationRecord[]): void { + if (records.length === 0) return + + const addedNodes = new Set() + const removedNodes = new Set() + + records.forEach((record) => { + if (collectNodeAttributeMutation(record, addedNodes, removedNodes)) return + collectChildListMutation(record, addedNodes, removedNodes) + }) + + if (addedNodes.size === 0 && removedNodes.size === 0) return + + const removedElements = new Set() + const addedElements = new Set() + + collectElements(removedNodes, false, removedElements) + collectElements(addedNodes, true, addedElements) + + this.removeNodeElements(removedElements) + this.addNodeElements(addedElements) + } + + private addNodeElements(elements: Iterable): void { + for (const element of elements) { + if (!element.matches(NODE_SELECTOR)) continue + if (this.nodeElements.has(element)) continue + + this.nodeElements.add(element) + this.notifyNodeElementAdded(element) + } + } + + private removeNodeElements(elements: Iterable): void { + for (const element of elements) { + if (!this.nodeElements.delete(element)) continue + this.notifyNodeElementRemoved(element) + } + } + + private notifyNodeElementAdded(element: Element): void { + NODE_INTERACTIONS.forEach((interaction) => { + if (!this.isInteractionRunning[interaction]) return + const override = this.elementOverrides[interaction].get(element) + if (override?.enabled === false) return + if (override?.enabled === true || this.autoTrack[interaction]) { + this.detectors[interaction].observe(element) + } + }) + } + + private notifyNodeElementRemoved(element: Element): void { + NODE_INTERACTIONS.forEach((interaction) => { + if (!this.isInteractionRunning[interaction]) return + this.detectors[interaction].unobserve(element) + }) + } + + private maybeStopNodeElementObservation(): void { + if (NODE_INTERACTIONS.some((interaction) => this.isInteractionRunning[interaction])) return + this.stopNodeElementObservation() + } + + private stopNodeElementObservation(): void { + this.nodeElementObserver?.disconnect() + this.nodeElementObserver = undefined + this.nodeElements.clear() + } +} diff --git a/packages/web/web-sdk/src/entry-tracking/events/click/createNodeClickDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/click/createNodeClickDetector.ts new file mode 100644 index 000000000..89b96f3cd --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/click/createNodeClickDetector.ts @@ -0,0 +1,119 @@ +import type { CoreStateful } from '@contentful/optimization-core' +import { createScopedLogger } from '@contentful/optimization-core/logger' +import { NODE_SELECTOR } from '../../../constants' +import type { NodeInteractionDetector } from '../../NodeInteractionDetector' +import { resolveNodeDataset } from '../../resolveNodeViewArgs' + +const logger = createScopedLogger('Web:NodeClickTracking') + +/** + * Minimal core shape required by {@link createNodeClickDetector}. + * + * @public + */ +export type NodeClickTrackingCore = Pick + +const CLICKABLE_SELECTOR = [ + 'a[href]', + 'button', + 'input:not([type="hidden"])', + 'select', + 'textarea', + 'summary', + '[role="button"]', + '[role="link"]', + '[onclick]', + '[data-ctfl-clickable="true"]', +].join(',') + +function hasOnclickPropertyHandler(element: Element): boolean { + return element instanceof HTMLElement && typeof element.onclick === 'function' +} + +function toEventTargetElement(event: Event): Element | undefined { + if (event.target instanceof Element) return event.target + + const { target: targetNode } = event + + return targetNode instanceof Node ? (targetNode.parentElement ?? undefined) : undefined +} + +/** + * Create a document-delegated click detector keyed on `data-ctfl-node-id` + * elements. On click, walks from the event target up to the nearest observed + * node element, verifies a clickable path exists in between, then fires + * `core.trackClick` with the resolved dataset mapped to `componentId` / + * `experienceId` / `variantIndex`. + * + * @internal + */ +function hasClickablePathBetween(from: Element, until: Element): boolean { + if (from.closest(CLICKABLE_SELECTOR) !== null) return true + + let current: Element | null = from + const { parentElement: stop } = until + while (current && current !== stop) { + if (hasOnclickPropertyHandler(current)) return true + const { parentElement }: { parentElement: Element | null } = current + current = parentElement + } + return false +} + +export function createNodeClickDetector(core: NodeClickTrackingCore): NodeInteractionDetector { + const observed = new Set() + let listening = false + + const onDocumentClick = (event: MouseEvent): void => { + const eventTarget = toEventTargetElement(event) + if (!eventTarget) return + + const nodeElement = eventTarget.closest(NODE_SELECTOR) + if (!nodeElement || !observed.has(nodeElement)) return + + if (!hasClickablePathBetween(eventTarget, nodeElement)) return + + const resolved = resolveNodeDataset(nodeElement) + if (!resolved) { + logger.warn('No node data found in node click callback; expected data-ctfl-* attributes') + return + } + + void core.trackClick({ + componentId: resolved.entityId, + experienceId: resolved.parentExperienceId ?? resolved.optimizationId, + variantIndex: resolved.variantIndex, + }) + } + + const ensureListener = (): void => { + if (listening || typeof document === 'undefined') return + document.addEventListener('click', onDocumentClick, true) + listening = true + } + + const maybeRemoveListener = (): void => { + if (!listening || observed.size > 0) return + if (typeof document === 'undefined') return + document.removeEventListener('click', onDocumentClick, true) + listening = false + } + + return { + observe: (element): void => { + observed.add(element) + ensureListener() + }, + unobserve: (element): void => { + if (!observed.delete(element)) return + maybeRemoveListener() + }, + disconnect: (): void => { + observed.clear() + if (listening && typeof document !== 'undefined') { + document.removeEventListener('click', onDocumentClick, true) + } + listening = false + }, + } +} diff --git a/packages/web/web-sdk/src/entry-tracking/events/click/index.ts b/packages/web/web-sdk/src/entry-tracking/events/click/index.ts index 34a8745ce..22c66b792 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/click/index.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/click/index.ts @@ -1 +1,2 @@ export * from './createEntryClickDetector' +export * from './createNodeClickDetector' diff --git a/packages/web/web-sdk/src/entry-tracking/events/hover/createNodeHoverDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/createNodeHoverDetector.ts new file mode 100644 index 000000000..08708195c --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/createNodeHoverDetector.ts @@ -0,0 +1,54 @@ +import type { CoreStateful } from '@contentful/optimization-core' +import type { NodeInteractionDetector } from '../../NodeInteractionDetector' +import { resolveNodeDataset } from '../../resolveNodeViewArgs' +import type { + ElementHoverCallbackInfo, + ElementHoverObserverOptions, +} from './element-hover-observer-support' +import ElementHoverObserver from './ElementHoverObserver' + +/** + * Minimal core shape required by {@link createNodeHoverDetector}. + * + * @public + */ +export type NodeHoverTrackingCore = Pick + +/** + * Create an {@link ElementHoverObserver}-backed detector that fires + * `trackHover` once a `data-ctfl-node-id` element has been hovered long enough + * to satisfy the dwell threshold. + * + * @internal + */ +export function createNodeHoverDetector( + core: NodeHoverTrackingCore, + options?: ElementHoverObserverOptions, +): NodeInteractionDetector { + const callback = async (element: Element, info: ElementHoverCallbackInfo): Promise => { + const resolved = resolveNodeDataset(element) + if (!resolved) return + + await core.trackHover({ + componentId: resolved.entityId, + experienceId: resolved.parentExperienceId ?? resolved.optimizationId, + variantIndex: resolved.variantIndex, + hoverId: info.hoverId, + hoverDurationMs: Math.max(0, Math.round(info.totalHoverMs)), + }) + } + + const observer = new ElementHoverObserver(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/events/hover/index.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/index.ts index 33f224b91..2929254bb 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/hover/index.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/index.ts @@ -2,4 +2,5 @@ export * from './ElementHoverObserver' export { default as ElementHoverObserver } from './ElementHoverObserver' export * from './createEntryHoverDetector' +export * from './createNodeHoverDetector' export * from './element-hover-observer-support' 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 000000000..a85152451 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.test.ts @@ -0,0 +1,63 @@ +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', + ctflVariantIndex: '1', + }) + + 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 000000000..87b8830b8 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createNodeViewDetector.ts @@ -0,0 +1,65 @@ +import type { CoreStateful } from '@contentful/optimization-core' +import { resolveNodeViewArgs } from '../../resolveNodeViewArgs' +import type { + ElementViewCallbackInfo, + ElementViewObserverOptions, +} from './element-view-observer-support' +import ElementViewObserver from './ElementViewObserver' + +/** + * Minimal core shape required by {@link createNodeViewDetector}. + * + * @public + */ +export type NodeViewTrackingCore = Pick + +/** + * 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 +} + +/** + * Create an {@link ElementViewObserver}-backed detector that fires + * `trackNodeView` once a `data-ctfl-node-id` element has dwelled in the + * viewport. + * + * @param core - Object exposing {@link NodeViewTrackingCore.trackNodeView}. + * @param options - Optional {@link ElementViewObserver} configuration. + * @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/events/view/index.ts b/packages/web/web-sdk/src/entry-tracking/events/view/index.ts index f4b858b7d..44b0e9752 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/index.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/index.ts @@ -2,4 +2,5 @@ export * from './ElementViewObserver' export { default as ElementViewObserver } from './ElementViewObserver' export * from './createEntryViewDetector' +export * from './createNodeViewDetector' export * from './element-view-observer-support' diff --git a/packages/web/web-sdk/src/entry-tracking/index.ts b/packages/web/web-sdk/src/entry-tracking/index.ts index a7a304b00..e5c4e2b13 100644 --- a/packages/web/web-sdk/src/entry-tracking/index.ts +++ b/packages/web/web-sdk/src/entry-tracking/index.ts @@ -1,7 +1,11 @@ export type * from './EntryInteractionDetector' export * from './EntryInteractionRuntime' +export type * from './NodeInteractionDetector' +export * from './NodeInteractionRuntime' export * from './resolveAutoTrackEntryInteractionOptions' export * from './resolveEntryInteractionElementOverride' +export * from './resolveNodeViewArgs' +export * from './resolveNodeViewPayload' export * from './resolveTrackingPayload' export * from './events' diff --git a/packages/web/web-sdk/src/entry-tracking/resolveNodeViewArgs.ts b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewArgs.ts new file mode 100644 index 000000000..7894f9e2f --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewArgs.ts @@ -0,0 +1,98 @@ +import type { NodeViewTrackingArgs } from '@contentful/optimization-core' +import type { ElementViewCallbackInfo } from './events/view/element-view-observer-support' +import { isHtmlOrSvgElement } from './isHtmlOrSvgElement' +import type { ResolvedNodeMetadata } from './resolveNodeViewPayload' + +function isKnownEntityKind(kind: string): kind is ResolvedNodeMetadata['entityKind'] { + return kind === 'Experience' || kind === 'Fragment' +} + +function parseVariantIndex(value: string | undefined): number | undefined { + if (!value || !/^\d+$/.test(value)) return undefined + + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : undefined +} + +/** + * Metadata resolved off a node element's `data-ctfl-*` dataset. Excludes the + * `viewId`/`viewDurationMs` fields the viewport observer supplies at fire time. + * + * @internal + */ +export type ResolvedNodeDataset = ResolvedNodeMetadata + +/** + * Read the `data-ctfl-*` dataset stamped by the `experiences-adapter` node + * binding into a {@link ResolvedNodeDataset}. Returns `undefined` when required + * attributes are missing or the entity kind is not one the SDK attributes to. + * + * @param element - Candidate DOM element carrying `data-ctfl-*` attributes. + * @returns Resolved dataset metadata or `undefined` if the element is not + * attributable. + * + * @internal + */ +export function resolveNodeDataset(element: Element): ResolvedNodeDataset | undefined { + if (!isHtmlOrSvgElement(element)) return undefined + + const { dataset } = element + const { + ctflNodeId, + ctflEntityId, + ctflEntityKind, + ctflOptimizationId, + ctflVariant, + ctflVariantIndex, + ctflParentExperienceId, + } = dataset + + const variantIndex = parseVariantIndex(ctflVariantIndex) + + if ( + !ctflNodeId || + !ctflEntityId || + !ctflEntityKind || + !ctflOptimizationId || + !ctflVariant || + variantIndex === undefined + ) { + return undefined + } + + if (!isKnownEntityKind(ctflEntityKind)) return undefined + + return { + entityId: ctflEntityId, + entityKind: ctflEntityKind, + optimizationId: ctflOptimizationId, + variantId: ctflVariant, + variantIndex, + parentExperienceId: ctflParentExperienceId, + } +} + +/** + * Compose {@link NodeViewTrackingArgs} from an element's `data-ctfl-*` dataset + * and the timing fields supplied by the viewport observer. + * + * @internal + */ +export function resolveNodeViewArgs( + element: Element, + info: ElementViewCallbackInfo, +): NodeViewTrackingArgs | undefined { + const resolved = resolveNodeDataset(element) + if (resolved === undefined) return undefined + + return { + entityId: resolved.entityId, + entityKind: resolved.entityKind, + optimizationId: resolved.optimizationId, + variantId: resolved.variantId, + variantIndex: resolved.variantIndex, + parentExperienceId: resolved.parentExperienceId, + viewId: info.viewId, + viewDurationMs: Math.max(0, Math.round(info.totalVisibleMs)), + } +} 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 000000000..4ed63e4d0 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.test.ts @@ -0,0 +1,229 @@ +import { + SourceMap, + type SourceMapLayer, + type SourceMapNode, + type SourceMapVariant, +} from '@contentful/optimization-core/api-schemas' +import { describe, expect, it } from '@rstest/core' +import { resolveNodeViewPayload } from './resolveNodeViewPayload' + +function makeSourceMap(input: { + variants?: SourceMapVariant[] + layers?: SourceMapLayer[] + nodes?: Record +}): SourceMap { + return SourceMap.parse({ + version: 1, + variants: input.variants ?? [], + spaces: [], + environments: [], + locales: [], + entries: [], + assets: [], + layers: input.layers ?? [], + dataAssemblies: [], + nodes: input.nodes ?? {}, + }) +} + +function node(layers: number[], scope: number): SourceMapNode { + return { layers, scope, contentProperties: [] } +} + +describe('resolveNodeViewPayload', () => { + it('resolves metadata for a node scoped to an Experience layer', () => { + const sourceMap = makeSourceMap({ + 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': node([0], 0), + 'node-frag': node([1, 0], 1), + }, + }) + + const result = resolveNodeViewPayload('node-exp', sourceMap) + + expect(result).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'exp-id', + variantId: 'variant-a', + variantIndex: 1, + parentExperienceId: undefined, + }) + }) + + it('prefers explicit variant metadata from sourceMap variants', () => { + const sourceMap = makeSourceMap({ + variants: [ + { type: 'personalization', id: 'default' }, + { + type: 'personalization', + id: 'variant-entry-id', + experienceId: 'exp-id', + optimizationId: 'opt-id', + variantId: 'variant-a', + variantIndex: 1, + }, + ], + layers: [{ kind: 'Experience', id: 'exp-id', variants: [1] }], + nodes: { 'node-1': node([0], 0) }, + }) + + const result = resolveNodeViewPayload('node-1', sourceMap) + + expect(result).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'opt-id', + variantId: 'variant-a', + variantIndex: 1, + parentExperienceId: undefined, + }) + }) + + it('resolves metadata for a node scoped to a Fragment layer', () => { + const sourceMap = makeSourceMap({ + 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-frag': node([1, 0], 1), + }, + }) + + const result = resolveNodeViewPayload('node-frag', sourceMap) + + expect(result).toEqual({ + entityId: 'frag-id', + entityKind: 'Fragment', + optimizationId: 'frag-id', + variantId: 'default', + variantIndex: 0, + parentExperienceId: 'exp-id', + }) + }) + + it('returns null when nodeId is not in sourceMap', () => { + const sourceMap = makeSourceMap({ + variants: [{ type: 'personalization', id: 'default' }], + layers: [{ kind: 'Experience', id: 'exp-id', variants: [0] }], + nodes: { 'node-1': node([0], 0) }, + }) + + expect(resolveNodeViewPayload('nonexistent', sourceMap)).toBeNull() + }) + + it('returns null when the scope layer index is not present in the node layer chain', () => { + const sourceMap = makeSourceMap({ + variants: [{ type: 'personalization', id: 'default' }], + layers: [{ kind: 'Experience', id: 'exp-id', variants: [0] }], + nodes: { 'node-1': node([0], 99) }, + }) + + expect(resolveNodeViewPayload('node-1', sourceMap)).toBeNull() + }) + + it('skips scope layer without a resolved variantId and falls through to the next layer', () => { + const sourceMap = makeSourceMap({ + variants: [ + { type: 'personalization', id: 'default' }, + { type: 'personalization', id: 'variant-b' }, + ], + layers: [ + // No variant reference on this fragment — .variants[0] === undefined → no variantId. + { kind: 'Fragment', id: 'frag-no-variants', variants: [] }, + { kind: 'Experience', id: 'exp-id', variants: [1] }, + ], + nodes: { + 'node-1': node([0, 1], 0), + }, + }) + + const result = resolveNodeViewPayload('node-1', sourceMap) + + expect(result).toEqual({ + entityId: 'exp-id', + entityKind: 'Experience', + optimizationId: 'exp-id', + variantId: 'variant-b', + variantIndex: 1, + parentExperienceId: undefined, + }) + }) + + it('does not use unrelated global layers outside the node layer chain', () => { + const sourceMap = makeSourceMap({ + variants: [ + { type: 'personalization', id: 'default' }, + { type: 'personalization', id: 'variant-c' }, + ], + layers: [ + { kind: 'Fragment', id: 'frag-no-variants', variants: [] }, + { kind: 'Experience', id: 'unrelated-exp', variants: [1] }, + ], + nodes: { + 'node-1': node([0], 0), + }, + }) + + expect(resolveNodeViewPayload('node-1', sourceMap)).toBeNull() + }) + + it('returns null for non-attributable layer kinds (InlineFragment, Slot, Template, ComponentType)', () => { + const sourceMap = makeSourceMap({ + variants: [{ type: 'personalization', id: 'v' }], + layers: [ + { kind: 'InlineFragment', id: 'inline-frag-id' }, + { kind: 'Slot', id: 'slot-id' }, + { kind: 'Template', id: 'template-id' }, + { kind: 'ComponentType', id: 'component-type-id' }, + ], + nodes: { + 'node-inline': node([0], 0), + 'node-slot': node([1], 1), + 'node-template': node([2], 2), + 'node-component': node([3], 3), + }, + }) + + expect(resolveNodeViewPayload('node-inline', sourceMap)).toBeNull() + expect(resolveNodeViewPayload('node-slot', sourceMap)).toBeNull() + expect(resolveNodeViewPayload('node-template', sourceMap)).toBeNull() + expect(resolveNodeViewPayload('node-component', sourceMap)).toBeNull() + }) + + it('sets parentExperienceId to the nearest ancestor Experience layer above the attributed layer', () => { + const sourceMap = makeSourceMap({ + variants: [ + { type: 'personalization', id: 'default' }, + { type: 'personalization', id: 'variant-x' }, + ], + layers: [ + { kind: 'Fragment', id: 'frag-id', variants: [1] }, + { kind: 'Experience', id: 'parent-exp-id', variants: [] }, + ], + nodes: { + 'node-1': node([0, 1], 0), + }, + }) + + const result = resolveNodeViewPayload('node-1', sourceMap) + + expect(result?.parentExperienceId).toBe('parent-exp-id') + expect(result?.entityId).toBe('frag-id') + expect(result?.entityKind).toBe('Fragment') + expect(result?.variantIndex).toBe(1) + }) +}) 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 000000000..a75763b11 --- /dev/null +++ b/packages/web/web-sdk/src/entry-tracking/resolveNodeViewPayload.ts @@ -0,0 +1,182 @@ +import type { + SourceMap, + SourceMapLayer, + SourceMapVariant, +} from '@contentful/optimization-core/api-schemas' + +/** + * Metadata resolved for a rendered node inside a Ninetailed Experience/Fragment + * subtree. Consumed by the ref-callback in + * `@contentful/optimization-react-web/experiences-adapter` and stamped onto + * the DOM as `data-ctfl-*` attributes. + * + * @remarks + * Excludes timing fields (`viewId`, `viewDurationMs`) supplied by the viewport + * observer at fire time. + * + * @internal + */ +export interface ResolvedNodeMetadata { + entityId: string + entityKind: 'Experience' | 'Fragment' + optimizationId: string + variantId: string + variantIndex: number + parentExperienceId?: string +} + +const KNOWN_ENTITY_KINDS = new Set(['Experience', 'Fragment']) + +interface AttributableLayer { + entityKind: ResolvedNodeMetadata['entityKind'] + entityId: string + optimizationId?: string + variantId?: string + variantIndex?: number +} + +type AttributableSourceMapLayer = Extract + +function isAttributableSourceMapLayer(layer: SourceMapLayer): layer is AttributableSourceMapLayer { + return KNOWN_ENTITY_KINDS.has(layer.kind) +} + +function resolveVariantIndex( + variantEntry: SourceMapVariant, + fallbackVariantIndex: number | undefined, +): number | undefined { + if (variantEntry.variantIndex !== undefined) { + return variantEntry.variantIndex + } + + if (variantEntry.id === 'default') { + return 0 + } + + return fallbackVariantIndex +} + +function resolveVariantMetadata( + variantEntry: SourceMapVariant | undefined, + fallbackVariantIndex: number | undefined, + fallbackOptimizationId: string, +): Pick { + if (variantEntry === undefined) { + return {} + } + + return { + variantId: variantEntry.variantId ?? variantEntry.id, + variantIndex: resolveVariantIndex(variantEntry, fallbackVariantIndex), + optimizationId: variantEntry.optimizationId ?? fallbackOptimizationId, + } +} + +function resolveAttributableLayer( + layerIndex: number | undefined, + layers: SourceMapLayer[], + variants: SourceMapVariant[], +): AttributableLayer | undefined { + if (layerIndex === undefined) { + return undefined + } + + const { [layerIndex]: layer } = layers + if (!layer || !isAttributableSourceMapLayer(layer)) { + return undefined + } + + const { variants: layerVariants } = layer + const { 0: firstVariantIndex } = layerVariants + const variantEntry = firstVariantIndex !== undefined ? variants[firstVariantIndex] : undefined + const variantMetadata = resolveVariantMetadata(variantEntry, firstVariantIndex, layer.id) + + return { entityKind: layer.kind, entityId: layer.id, ...variantMetadata } +} + +function findAttributableLayer( + nodeLayers: number[], + scopePosition: number, + layers: SourceMapLayer[], + variants: SourceMapVariant[], +): { layer: AttributableLayer; nodeIndex: number } | undefined { + for (let i = scopePosition; i < nodeLayers.length; i++) { + const { [i]: layerIndex } = nodeLayers + const attributable = resolveAttributableLayer(layerIndex, layers, variants) + if (attributable?.variantId) { + return { layer: attributable, nodeIndex: i } + } + } + return undefined +} + +function findParentExperienceId( + nodeLayers: number[], + attributedLayerNodeIndex: number, + layers: SourceMapLayer[], +): 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 resolved + * `variantId`. If no such layer is found the node cannot be attributed and the + * function returns `null`. + * + * `KNOWN_ENTITY_KINDS` is narrowed to `Experience | Fragment` — the only + * layer kinds that carry a `variants` reference in the shipped assemblies + * contract. + * + * @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 `null` when the node is absent or has no + * attributable layer. + * + * @internal + */ +export function resolveNodeViewPayload( + nodeId: string, + sourceMap: SourceMap, +): ResolvedNodeMetadata | null { + const { nodes, layers, variants } = sourceMap + const { [nodeId]: node } = nodes + if (node === undefined) { + return null + } + + const { layers: nodeLayers, scope } = node + const scopePosition = nodeLayers.indexOf(scope) + if (scopePosition < 0) { + return null + } + + const attributed = findAttributableLayer(nodeLayers, scopePosition, layers, variants) + if (attributed === undefined) { + return null + } + + 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 ?? '', + variantIndex: attributed.layer.variantIndex ?? 0, + parentExperienceId, + } +} diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index 8cd516724..95a100fd2 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -26,6 +26,7 @@ export { OPTIMIZATION_WEB_SDK_VERSION, } from './constants' export * from './ContentfulOptimization' +export { NodeInteractionRuntime, resolveNodeViewPayload } from './entry-tracking' export type { AutoTrackEntryInteractionOptions, EntryClickInteractionElementOptions, @@ -42,6 +43,11 @@ export type { EntryInteractionTrackers, EntryViewInteractionElementOptions, EntryViewInteractionStartOptions, + NodeInteraction, + NodeInteractionRuntimeCore, + NodeInteractionRuntimeOptions, + OptimizationTrackingApi as NodeInteractionTrackingApi, + ResolvedNodeMetadata, } from './entry-tracking' export * from './handlers/beaconHandler' export * from './storage/LocalStore'