diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index 577de294..41458dc4 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -25,7 +25,7 @@ export class ControlPanel { // This is an active exposure stream. Core does not mark one-off flag reads as // tracked until a flag-view event is actually accepted. protected readonly booleanFlag = fromSdkState(() => - this.optimization.ifBrowser((sdk) => sdk.states.flag('boolean')), + this.optimization.runtime().states.flag('boolean'), ) constructor() { @@ -39,22 +39,20 @@ export class ControlPanel { } protected toggleConsent(): void { - this.optimization.ifBrowser((sdk) => { - sdk.consent(this.consent() !== true) - }) + this.optimization.runtime().consent(this.consent() !== true) } protected identify(): void { - this.optimization.ifBrowser((sdk) => { - void sdk.identify({ userId: 'charles', traits: { identified: true } }) + void this.optimization.runtime().identify({ + userId: 'charles', + traits: { identified: true }, }) } protected reset(): void { - this.optimization.ifBrowser((sdk) => { - sdk.reset() - void sdk.page() - }) + const runtime = this.optimization.runtime() + runtime.reset() + void runtime.page() } protected trackConversion(): void { diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts index 5372f604..000492fe 100644 --- a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts @@ -2,9 +2,12 @@ import { NgTemplateOutlet } from '@angular/common' import { Component, computed, forwardRef, inject, input } from '@angular/core' import { DomSanitizer, type SafeHtml } from '@angular/platform-browser' import { + isMergeTagEntry, isRecord, isResolvedContentfulEntry, isRichTextDocument, + isUnresolvedEntryLink, + type MergeTagEntry, } from '@contentful/optimization-web/api-schemas' import { BLOCKS, INLINES } from '@contentful/rich-text-types' import { @@ -17,6 +20,7 @@ import { import type { ContentEntrySkeleton, ContentfulEntry } from '../../services/contentful-client' import { injectContentfulEntry } from '../../services/entry' import { NgLiveUpdates } from '../../services/live-updates' +import { NgContentfulOptimization } from '../../services/optimization' // — Badge — @@ -57,8 +61,26 @@ function escape(text: string): string { } type NodeRenderer = (children: () => string, data: Record) => string +type MergeTagValueResolver = (target: MergeTagEntry) => string | undefined + +/** + * Substitution outcome tracked alongside a single rich-text walk. Callers pass + * a fresh cell in; the walker flips `resolved` to true or false depending on + * whether the first encountered merge tag returned a value. Kept out of the + * entry-resolution layer so it lives next to the badge that consumes it — + * mirrors the Next.js reference which resolves at render time via + * `OptimizedEntryRenderContext.getMergeTagValue`. + */ +interface MergeTagRenderState { + resolved: boolean | undefined +} + +// `INLINES.EMBEDDED_ENTRY` is a string enum member; widen it once at module +// scope so the render walk compares plain strings and `@typescript-eslint/no- +// unsafe-enum-comparison` stays quiet without a cast at the call site. +const EMBEDDED_ENTRY_NODE_TYPE: string = INLINES.EMBEDDED_ENTRY -const RENDERERS: Partial> = { +const BLOCK_RENDERERS: Partial> = { [BLOCKS.PARAGRAPH]: (children) => `

${children()}

`, [BLOCKS.HEADING_1]: (children) => `

${children()}

`, [BLOCKS.HEADING_2]: (children) => `

${children()}

`, @@ -79,16 +101,38 @@ const RENDERERS: Partial> = { }, } -function renderNode(node: unknown): string { +function renderMergeTag( + data: Record, + getMergeTagValue: MergeTagValueResolver | undefined, + state: MergeTagRenderState, +): string { + if (!('target' in data)) return '' + const { target } = data + if (isUnresolvedEntryLink(target) || !isMergeTagEntry(target)) return '' + const value = getMergeTagValue?.(target) + if (value !== undefined) { + state.resolved = true + return escape(value) + } + state.resolved ??= false + return typeof target.fields.nt_fallback === 'string' ? escape(target.fields.nt_fallback) : '' +} + +function renderNode( + node: unknown, + getMergeTagValue: MergeTagValueResolver | undefined, + state: MergeTagRenderState, +): string { if (!isRecord(node)) return '' const nodeType = typeof node.nodeType === 'string' ? node.nodeType : '' const value = typeof node.value === 'string' ? node.value : '' const content = Array.isArray(node.content) ? node.content : [] const data = isRecord(node.data) ? node.data : {} - const children = (): string => content.map((c) => renderNode(c)).join('') + const children = (): string => content.map((c) => renderNode(c, getMergeTagValue, state)).join('') if (nodeType === 'text') return escape(value) - const { [nodeType]: renderer } = RENDERERS + if (nodeType === EMBEDDED_ENTRY_NODE_TYPE) return renderMergeTag(data, getMergeTagValue, state) + const { [nodeType]: renderer } = BLOCK_RENDERERS return renderer !== undefined ? renderer(children, data) : children() } @@ -108,6 +152,7 @@ export class EntryCard { private readonly sanitizer = inject(DomSanitizer) private readonly liveUpdatesService = inject(NgLiveUpdates) + private readonly optimization = inject(NgContentfulOptimization) private readonly isLive = computed(() => { if (this.liveUpdatesService.previewPanelVisible()) return true @@ -120,14 +165,29 @@ export class EntryCard { manualTracking: this.manualTracking, }) - protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId) - protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined) - protected readonly richTextHtml = computed(() => { + // Rich text and merge-tag detection share a single walk so the badge signal + // reflects the substitution outcome for exactly this render. + private readonly renderedRichText = computed< + { html: SafeHtml; mergeTagResolved: boolean | undefined } | undefined + >(() => { const { entry } = this.resolved() const doc = Object.values(entry.fields).find(isRichTextDocument) if (!doc) return undefined - return this.sanitizer.bypassSecurityTrustHtml(renderNode(doc)) + const profile = this.optimization.profile() + const runtime = this.optimization.runtime() + const getMergeTagValue = profile + ? (target: MergeTagEntry): string | undefined => runtime.getMergeTagValue(target, profile) + : undefined + const state: MergeTagRenderState = { resolved: undefined } + const html = this.sanitizer.bypassSecurityTrustHtml(renderNode(doc, getMergeTagValue, state)) + return { html, mergeTagResolved: state.resolved } }) + + protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId) + protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined) + protected readonly richTextHtml = computed( + () => this.renderedRichText()?.html, + ) protected readonly entryText = computed(() => { const text: unknown = this.resolved().entry.fields.text return typeof text === 'string' ? text : 'No content' @@ -139,8 +199,7 @@ export class EntryCard { : [] }) protected readonly badges = computed(() => { - const r = this.resolved() - const mergeTag = mergeTagKey(r.mergeTagResolved) + const mergeTag = mergeTagKey(this.renderedRichText()?.mergeTagResolved) const scenario = this.clickScenario() const keys: BadgeKey[] = [ ...(mergeTag ? [mergeTag] : []), diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index 2983ad43..4606ca10 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -1,5 +1,6 @@ -import { Component, DestroyRef, computed, inject, signal } from '@angular/core' +import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' +import type { OptimizationEventStreamEvent } from '@contentful/optimization-web/core-sdk' import { interval } from 'rxjs' import { NgContentfulOptimization } from '../../services/optimization' @@ -28,18 +29,29 @@ function timeAgo(firedAt: number, now: number): string { return `${Math.floor(m / MINUTES_PER_HOUR)}h` } +type StreamEvent = OptimizationEventStreamEvent +type EventOfType = Extract + @Component({ selector: 'app-tracking-log', templateUrl: './index.html', }) export class TrackingLog { private readonly optimization = inject(NgContentfulOptimization) + private readonly destroyRef = inject(DestroyRef) private readonly events = signal>(new Map()) private readonly rawEventsCount = signal(0) private readonly tick = toSignal(interval(TICK_INTERVAL_SECONDS * MS_PER_SECOND), { initialValue: 0, }) + + // Per-event-type sequence counters. Externalized from the dispatch so each + // handler stays a straight input→track mapping without threading closure + // state through the switch. + private pageSeq = 0 + private componentSeq = 0 + protected readonly rawEventsDisplay = this.rawEventsCount.asReadonly() protected readonly displayEvents = computed(() => { this.tick() @@ -50,80 +62,99 @@ export class TrackingLog { }) constructor() { - const { optimization } = this - const { context } = optimization - if (context.platform !== 'browser') return - const { sdk } = context - - let pageSeq = 0 - let componentSeq = 0 - const sub = sdk.states.eventStream.subscribe((raw) => { - if (raw != null) { - this.rawEventsCount.update((n) => n + 1) - } - switch (raw?.type) { - case 'page': { - const { - properties: { url }, - } = raw - pageSeq += 1 - const pathname = (() => { - try { - return new URL(url, window.location.origin).pathname - } catch { - return url - } - })() - this.track({ type: 'page', value: pathname, key: `page-${pageSeq}-${url}` }) - break - } - case 'component': { - const { componentId, viewId, viewDurationMs } = raw - if (viewId) { - this.track({ - type: 'view', - value: componentId, - key: `view-${viewId}`, - viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined, - }) - } else { - componentSeq += 1 - this.track( - { type: 'comp', value: componentId, key: `component-${componentId}-${componentSeq}` }, - `event-component-${componentId}`, - ) - } - break - } - case 'component_hover': { - const { componentId, hoverId, hoverDurationMs } = raw - if (hoverId) { - this.track({ - type: 'hover', - value: componentId, - key: `component_hover-hover-${hoverId}`, - hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined, - hoverId, - }) - } else { - this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) - } - break - } - case 'component_click': { - const { componentId } = raw - this.track({ type: 'click', value: componentId, key: `component_click-${componentId}` }) - break - } - default: - break - } + let sub: { unsubscribe: () => void } | undefined = undefined + + // Re-subscribe when the runtime swaps from the SSR snapshot runtime to + // the live SDK. The snapshot runtime's static eventStream never emits, so + // the initial subscription is a harmless no-op that is torn down on swap. + effect(() => { + const runtime = this.optimization.runtime() + sub?.unsubscribe() + sub = runtime.states.eventStream.subscribe((raw) => { + this.dispatch(raw) + }) }) - inject(DestroyRef).onDestroy(() => { - sub.unsubscribe() + + this.destroyRef.onDestroy(() => { + sub?.unsubscribe() }) } + private dispatch(raw: StreamEvent | undefined): void { + if (raw == null) return + this.rawEventsCount.update((n) => n + 1) + + switch (raw.type) { + case 'page': + this.handlePage(raw) + break + case 'component': + this.handleComponent(raw) + break + case 'component_hover': + this.handleHover(raw) + break + case 'component_click': + this.handleClick(raw) + break + default: + break + } + } + + private handlePage(raw: EventOfType<'page'>): void { + const { + properties: { url }, + } = raw + this.pageSeq += 1 + const pathname = (() => { + try { + return new URL(url, window.location.origin).pathname + } catch { + return url + } + })() + this.track({ type: 'page', value: pathname, key: `page-${this.pageSeq}-${url}` }) + } + + private handleComponent(raw: EventOfType<'component'>): void { + const { componentId, viewId, viewDurationMs } = raw + if (viewId) { + this.track({ + type: 'view', + value: componentId, + key: `view-${viewId}`, + viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined, + }) + return + } + this.componentSeq += 1 + this.track( + { type: 'comp', value: componentId, key: `component-${componentId}-${this.componentSeq}` }, + `event-component-${componentId}`, + ) + } + + private handleHover(raw: EventOfType<'component_hover'>): void { + const { componentId, hoverId, hoverDurationMs } = raw + if (hoverId) { + this.track({ + type: 'hover', + value: componentId, + key: `component_hover-hover-${hoverId}`, + hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined, + hoverId, + }) + return + } + this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + } + + private handleClick(raw: EventOfType<'component_click'>): void { + const { componentId } = raw + this.track({ type: 'click', value: componentId, key: `component_click-${componentId}` }) + } + private track( event: Omit, testId?: string, diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts index 5a1db829..e1d8685b 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -30,12 +30,10 @@ export class PageTwo { } protected readonly trackConversion = (): void => { - this.optimization.ifBrowser((sdk) => { - void sdk.trackView({ - componentId: PAGE_TWO_COMPONENT_ID, - viewId: crypto.randomUUID(), - viewDurationMs: 0, - }) + void this.optimization.runtime().trackView({ + componentId: PAGE_TWO_COMPONENT_ID, + viewId: crypto.randomUUID(), + viewDurationMs: 0, }) } } diff --git a/implementations/web-sdk_angular/src/app/services/contentful-client.ts b/implementations/web-sdk_angular/src/app/services/contentful-client.ts index ee103c97..8292a9cf 100644 --- a/implementations/web-sdk_angular/src/app/services/contentful-client.ts +++ b/implementations/web-sdk_angular/src/app/services/contentful-client.ts @@ -1,7 +1,14 @@ -import { inject, Injectable, resource, TransferState, type ResourceRef } from '@angular/core' +import { + inject, + Injectable, + makeStateKey, + resource, + TransferState, + type ResourceRef, + type StateKey, +} from '@angular/core' import type { ContentfulClientApi, Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' import { getOrCreateBaseClient, NG_CONTENTFUL_OPTIMIZATION_CONFIG } from '../config' -import { SERVER_BASELINES_KEY } from '../transfer-state-keys' export interface ContentEntryFields { text?: EntryFieldTypes.Text | EntryFieldTypes.RichText @@ -13,6 +20,14 @@ export type ContentfulEntry = Entry const INCLUDE_DEPTH = 10 +/** + * Baseline CDA entries keyed by id. Stamped by the SSR preflight so the + * browser can skip a duplicate CDA fetch on hydration. Lives next to + * {@link NgContentfulClient} because that class is the sole reader. + */ +export const SERVER_BASELINES_KEY: StateKey> = + makeStateKey>('ssr-baselines') + @Injectable({ providedIn: 'root' }) export class NgContentfulClient { private readonly client: ContentfulClientApi diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 9c706f1f..8dd41bf3 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -6,14 +6,11 @@ import { ElementRef, inject, signal, - TransferState, untracked, type Signal, } from '@angular/core' import type { Entry } from 'contentful' -import { SERVER_RESOLVED_ENTRIES_KEY } from '../transfer-state-keys' -import { resolveEntryMergeTags } from './merge-tags' import { NgContentfulOptimization } from './optimization' export type ObservationMode = 'auto' | 'manual' @@ -25,10 +22,12 @@ export interface ResolvedEntry { optimizationId: string | undefined sticky: boolean | undefined variantIndex: number | undefined - mergeTagResolved: boolean | undefined } function setupManualTracking(result: Signal, manualTracking: Signal): void { + // `runtime().tracking.*` is a NOOP on the server snapshot runtime and the + // real web SDK after hydration, so the wiring below can run unconditionally. + // `afterNextRender` already guards DOM access — it never fires on the server. const optimization = inject(NgContentfulOptimization) const elementRef = inject>(ElementRef) const destroyRef = inject(DestroyRef) @@ -40,18 +39,14 @@ function setupManualTracking(result: Signal, manualTracking: Sign }) function track(): void { - optimization.ifBrowser((sdk) => { - const { entryId, optimizationId, sticky, variantIndex } = result() - sdk.tracking.enableElement('views', elementRef.nativeElement, { - data: { entryId, optimizationId, sticky, variantIndex }, - }) + const { entryId, optimizationId, sticky, variantIndex } = result() + optimization.runtime().tracking.enableElement('views', elementRef.nativeElement, { + data: { entryId, optimizationId, sticky, variantIndex }, }) } function clear(): void { - optimization.ifBrowser((sdk) => { - sdk.tracking.clearElement('views', elementRef.nativeElement) - }) + optimization.runtime().tracking.clearElement('views', elementRef.nativeElement) } effect(() => { @@ -74,7 +69,6 @@ export function injectContentfulEntry({ manualTracking?: Signal }): Signal { const optimization = inject(NgContentfulOptimization) - const transferState = inject(TransferState) function liveRead(sig: Signal): T { if (isLive()) return sig() @@ -85,48 +79,21 @@ export function injectContentfulEntry({ return untracked(sig) ?? sig() } - const variant = computed(() => { + const result = computed(() => { + const runtime = optimization.runtime() const raw = entry() - if (optimization.context.platform === 'browser') { - return { - raw, - resolved: optimization.context.sdk.resolveOptimizedEntry( - raw, - liveRead(optimization.selectedOptimizations), - ), - } - } - // Server render: lift the server-resolved entry from TransferState if present - // so the initial HTML reflects the personalized variant. Falls back to the - // baseline when no handoff exists (e.g. consent denied — server skipped resolve). - const handoff = transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined) - return { + const resolved = runtime.resolveOptimizedEntry( raw, - resolved: handoff?.[raw.sys.id] ?? { entry: raw, selectedOptimization: undefined }, - } - }) - - const result = computed(() => { - const { raw, resolved } = variant() - const profile = liveRead(optimization.profile) - let mergeTagResolved: boolean | undefined = undefined - const entry = resolveEntryMergeTags(resolved.entry, (target) => { - const value = profile - ? optimization.ifBrowser((sdk) => sdk.getMergeTagValue(target, profile)) - : undefined - if (value !== undefined) mergeTagResolved = true - else mergeTagResolved ??= false - return value ?? target.fields.nt_fallback - }) + liveRead(optimization.selectedOptimizations), + ) return { - entry, + entry: resolved.entry, baselineId: raw.sys.id, entryId: resolved.entry.sys.id, optimizationId: resolved.selectedOptimization?.experienceId, sticky: resolved.selectedOptimization?.sticky, variantIndex: resolved.selectedOptimization?.variantIndex, - mergeTagResolved, } }) diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts index c23025e2..b1083cb9 100644 --- a/implementations/web-sdk_angular/src/app/services/live-updates.ts +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -14,11 +14,11 @@ export class NgLiveUpdates { private readonly optimization = inject(NgContentfulOptimization) private readonly globalLiveUpdatesSignal = signal(false) - private readonly previewPanelAttached = fromSdkState(() => - this.optimization.ifBrowser((sdk) => sdk.states.previewPanelAttached), + private readonly previewPanelAttached = fromSdkState( + () => this.optimization.runtime().states.previewPanelAttached, ) - private readonly previewPanelOpen = fromSdkState(() => - this.optimization.ifBrowser((sdk) => sdk.states.previewPanelOpen), + private readonly previewPanelOpen = fromSdkState( + () => this.optimization.runtime().states.previewPanelOpen, ) readonly globalLiveUpdates = this.globalLiveUpdatesSignal.asReadonly() diff --git a/implementations/web-sdk_angular/src/app/services/merge-tags.ts b/implementations/web-sdk_angular/src/app/services/merge-tags.ts deleted file mode 100644 index ac4dfebe..00000000 --- a/implementations/web-sdk_angular/src/app/services/merge-tags.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - isMergeTagEntry, - isRecord, - isRichTextDocument, - type MergeTagEntry, -} from '@contentful/optimization-web/api-schemas' -import { INLINES, type Text } from '@contentful/rich-text-types' -import type { Entry } from 'contentful' - -/** - * Resolves the substitution value for a single merge-tag entry. Returning - * `undefined` lets the walker fall back to `target.fields.nt_fallback`. - */ -export type MergeTagResolver = (target: MergeTagEntry) => string | undefined - -function resolveNode(node: unknown, resolveMergeTag: MergeTagResolver): unknown { - if (!isRecord(node)) return node - const { data } = node - if (node.nodeType === INLINES.EMBEDDED_ENTRY && isRecord(data)) { - const { target } = data - if (isMergeTagEntry(target)) { - return { - nodeType: 'text', - value: resolveMergeTag(target) ?? '', - marks: [], - data: {}, - } satisfies Text - } - } - if (Array.isArray(node.content)) { - return { ...node, content: node.content.map((child) => resolveNode(child, resolveMergeTag)) } - } - return node -} - -/** - * Walk every rich-text field on the entry and substitute merge-tag inline - * entries with text nodes produced by `resolveMergeTag`. Non-rich-text fields - * pass through unchanged. Runtime-agnostic — used by both the browser-side - * component layer and the SSR preflight. - */ -export function resolveEntryMergeTags(entry: Entry, resolveMergeTag: MergeTagResolver): Entry { - return Object.assign({}, entry, { - fields: Object.fromEntries( - Object.entries(entry.fields).map(([key, value]) => [ - key, - isRichTextDocument(value) ? resolveNode(value, resolveMergeTag) : value, - ]), - ), - }) as Entry -} diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index dc0fb344..e3d29bc4 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -3,6 +3,7 @@ import { DestroyRef, inject, Injectable, + makeStateKey, PLATFORM_ID, provideAppInitializer, REQUEST, @@ -11,22 +12,25 @@ import { TransferState, type EnvironmentProviders, type Signal, + type StateKey, + type WritableSignal, } from '@angular/core' import { NavigationEnd, Router } from '@angular/router' import type NodeContentfulOptimizationType from '@contentful/optimization-node' -import type { OptimizationData } from '@contentful/optimization-node/api-schemas' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import type { CoreStatelessRequest, UniversalEventBuilderArgs, } from '@contentful/optimization-node/core-sdk' import ContentfulOptimization from '@contentful/optimization-web' -import { - isResolvedContentfulEntry, - type Profile, - type SelectedOptimizationArray, -} from '@contentful/optimization-web/api-schemas' +import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' import { createScopedLogger } from '@contentful/optimization-web/logger' +import { + createWebSnapshotRuntime, + type OptimizationSnapshot, + type WebOptimizationRuntime, +} from '@contentful/optimization-web/runtime' import type { Entry } from 'contentful' import { PAGES } from 'e2e-web' import { filter } from 'rxjs/operators' @@ -36,35 +40,19 @@ import { NG_CONTENTFUL_OPTIMIZATION_CONFIG, resolveLogLevel, } from '../config' -import { - SERVER_BASELINES_KEY, - SERVER_OPTIMIZATION_KEY, - SERVER_RESOLVED_ENTRIES_KEY, - type ResolvedEntryData, - type ServerHandoff, -} from '../transfer-state-keys' import { fromSdkState } from '../utils' import { readConsentFromRequest } from './consent' -import { NgContentfulClient } from './contentful-client' -import { resolveEntryMergeTags } from './merge-tags' - -type NgContentfulOptimizationInstance = ContentfulOptimization +import { NgContentfulClient, SERVER_BASELINES_KEY } from './contentful-client' /** - * Runtime context exposed by the {@link NgContentfulOptimization} service. - * The `sdk` here is the **browser** SDK (`@contentful/optimization-web`), - * which reads `localStorage` at construction time and therefore cannot be - * instantiated server-side. Discriminating on `platform` lets callers branch - * on the runtime without dereferencing an optional chain. - * - * The **Node** SDK (`@contentful/optimization-node`) does run server-side, - * but it is intentionally not surfaced through this service — it is owned by - * the preflight at the bottom of this file and only its **results** cross - * into the browser bundle via `TransferState`. + * SSR handoff for the personalization runtime. Stamped by the server preflight, + * read on the browser to seed the initial snapshot runtime before the live SDK + * takes over. The shape matches {@link OptimizationSnapshot} so the same + * request-scoped payload backs `createSnapshotRuntime` on both sides of the + * hydration boundary. */ -type NgContentfulOptimizationContext = - | { readonly platform: 'server' } - | { readonly platform: 'browser'; readonly sdk: NgContentfulOptimizationInstance } +const SERVER_OPTIMIZATION_KEY: StateKey = + makeStateKey('ssr-optimization') /** * Shared SDK-config mapping used by both the browser Web SDK constructor and @@ -92,11 +80,12 @@ function toSdkConstructorArgs(config: NgContentfulOptimizationConfig): { } } -let instance: NgContentfulOptimizationInstance | undefined = undefined +let instance: ContentfulOptimization | undefined = undefined const previewPanelLogger = createScopedLogger('AngularReference:PreviewPanel') +const hydrationLogger = createScopedLogger('AngularReference:SsrHydration') async function attachPreviewPanel( - sdk: NgContentfulOptimizationInstance, + sdk: ContentfulOptimization, config: NgContentfulOptimizationConfig, ): Promise { const contentfulClient = getOrCreateBaseClient(config) @@ -108,9 +97,38 @@ async function attachPreviewPanel( }) } -function getOrCreateInstance( +// Kept as module-scope helpers (rather than instance methods) so SonarQube +// typescript:S7059 does not fire on in-constructor async work. + +function hydrateSnapshotAndPromote( + sdk: ContentfulOptimization, + snapshot: OptimizationSnapshot | undefined, + runtimeSignal: WritableSignal, +): void { + if (!snapshot?.data) { + runtimeSignal.set(sdk) + return + } + hydrateOptimizationData(sdk, snapshot.data) + .then(() => { + runtimeSignal.set(sdk) + }) + .catch((error: unknown) => { + hydrationLogger.warn('Failed to hydrate live SDK from SSR snapshot.', error) + runtimeSignal.set(sdk) + }) +} + +function attachPreviewPanelSafely( + sdk: ContentfulOptimization, config: NgContentfulOptimizationConfig, -): NgContentfulOptimizationInstance { +): void { + attachPreviewPanel(sdk, config).catch((error: unknown) => { + previewPanelLogger.warn('Failed to attach the Contentful Optimization preview panel.', error) + }) +} + +function getOrCreateInstance(config: NgContentfulOptimizationConfig): ContentfulOptimization { instance ??= new ContentfulOptimization({ ...toSdkConstructorArgs(config), autoTrackEntryInteraction: config.autoTrackEntryInteraction ?? { @@ -123,14 +141,18 @@ function getOrCreateInstance( } /** - * Single SDK service exposed to components. On the browser it owns a real - * {@link ContentfulOptimization} instance; on the server it surfaces the SSR - * handoff so templates render the personalised state without ever touching the - * Web SDK (which would crash on `localStorage`). + * Single SDK service exposed to components. Both server and browser see the + * same {@link WebOptimizationRuntime}: on the server (and during the initial + * client render) it is a read-only {@link createWebSnapshotRuntime} backed by + * the SSR handoff, with `tracking.*` and `trackCurrentPage` as inert no-ops; + * on the browser after construction it swaps to the live + * {@link ContentfulOptimization}. Every member — resolvers, `states`, event + * actions, and even the browser-only tracking imperatives — is safe to call + * unconditionally in components. */ @Injectable({ providedIn: 'root' }) export class NgContentfulOptimization { - readonly context: NgContentfulOptimizationContext + readonly runtime: Signal readonly consent: Signal readonly profile: Signal readonly selectedOptimizations: Signal @@ -141,45 +163,44 @@ export class NgContentfulOptimization { const destroyRef = inject(DestroyRef) const transferState = inject(TransferState) const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) - const handoff = transferState.get(SERVER_OPTIMIZATION_KEY, undefined) + const snapshot = transferState.get( + SERVER_OPTIMIZATION_KEY, + undefined, + ) + + const runtimeSignal = signal(createWebSnapshotRuntime(snapshot)) + this.runtime = runtimeSignal.asReadonly() + this.consent = fromSdkState(() => runtimeSignal().states.consent) + this.profile = fromSdkState(() => runtimeSignal().states.profile) + this.selectedOptimizations = fromSdkState(() => runtimeSignal().states.selectedOptimizations) if (!isBrowser) { - // Seed the read-only signals from the SSR handoff so server-rendered - // templates reflect the same consent/profile state the server preflight - // observed. Without this, JS-disabled clients would see "undefined" / - // "0 active optimizations" in the Utilities panel even though the entry - // markup is fully personalised. - this.context = { platform: 'server' } - this.consent = signal(handoff?.consent).asReadonly() - this.profile = signal(handoff?.profile).asReadonly() - this.selectedOptimizations = signal( - handoff?.selectedOptimizations, - ).asReadonly() + // Server render: the snapshot runtime satisfies the full seam. Reads + // flow through `states.*`, resolvers/getMergeTagValue are pure, event + // actions are inert dev-warn no-ops, and `tracking.*` is a NOOP object. return } const sdk = getOrCreateInstance(config) - this.context = { platform: 'browser', sdk } + + // Prime the live SDK with the server-computed snapshot before promoting + // it to the runtime signal, so the first live render matches the SSR + // HTML (same selectedOptimizations, same profile, same merge tags). + // With no server data (consent denied or preflight skipped), the snapshot + // runtime and the fresh live SDK already share the same initial state, so + // we can swap immediately. + hydrateSnapshotAndPromote(sdk, snapshot, runtimeSignal) if (config.previewPanel !== undefined) { - void attachPreviewPanel(sdk, config).catch((error: unknown) => { - previewPanelLogger.warn( - 'Failed to attach the Contentful Optimization preview panel.', - error, - ) - }) + attachPreviewPanelSafely(sdk, config) } - this.consent = fromSdkState(sdk.states.consent) - this.profile = fromSdkState(sdk.states.profile) - this.selectedOptimizations = fromSdkState(sdk.states.selectedOptimizations) - // Page events fire on every route change. The first NavigationEnd after // hydration is skipped when the server preflight already emitted page() // for the same route (consent was granted server-side) — without this // skip, analytics double-counts the SSR landing page. Subsequent // navigations always emit. - let skipNextPage = handoff?.consent ?? false + let skipNextPage = snapshot?.consent ?? false const routerSubscription = router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((e) => { @@ -198,16 +219,6 @@ export class NgContentfulOptimization { instance = undefined }) } - - /** - * Run an SDK side-effect on the browser. Returns the callback's value on the - * browser branch, and `undefined` on the server (where there is no SDK to - * call). Lets call sites avoid an `if (context.platform === 'browser')` - * narrowing dance for fire-and-forget toggles. - */ - ifBrowser(fn: (sdk: NgContentfulOptimizationInstance) => T): T | undefined { - return this.context.platform === 'browser' ? fn(this.context.sdk) : undefined - } } // ── Server-side preflight ────────────────────────────────────────────────── @@ -242,20 +253,6 @@ async function createServerOptimization( return new NodeContentfulOptimization(toSdkConstructorArgs(config)) } -/** - * Outcome of the server-side preflight for one SSR request. Discriminated on - * `consentGranted` so callers either get the full personalization context or - * a "no SDK work happened" branch — never a half-populated value. - */ -type ServerOptimizationData = - | { readonly consentGranted: false } - | { - readonly consentGranted: true - readonly data: OptimizationData - readonly profileId: string - readonly canPersistProfile: boolean - } - /** * Build an event context for the SSR `forRequest()` call so the server-side * page event carries the current route. Without this, route-targeted @@ -277,13 +274,25 @@ function createServerEventContext(request: Request, locale: string): UniversalEv } } -async function getServerOptimizationData( +interface ServerPreflightOutcome { + readonly snapshot: OptimizationSnapshot + readonly profileId: string | undefined + readonly canPersistProfile: boolean +} + +async function computeSnapshot( sdk: NodeContentfulOptimizationType, request: Request, consentGranted: boolean, locale: string, -): Promise { - if (!consentGranted) return { consentGranted: false } +): Promise { + if (!consentGranted) { + return { + snapshot: { consent: false, locale }, + profileId: undefined, + canPersistProfile: false, + } + } const anonymousId = readAnonymousId(request) const requestOptimization: CoreStatelessRequest = sdk.forRequest({ @@ -293,55 +302,26 @@ async function getServerOptimizationData( ...(anonymousId === undefined ? {} : { profile: { id: anonymousId } }), }) const pageResult = await requestOptimization.page() - if (!pageResult.accepted) return { consentGranted: false } - const data: OptimizationData | undefined = pageResult.data - if (!data) return { consentGranted: false } + if (!pageResult.accepted || !pageResult.data) { + return { + snapshot: { consent: false, locale }, + profileId: undefined, + canPersistProfile: false, + } + } return { - consentGranted: true, - data, - profileId: data.profile.id, + snapshot: { + consent: true, + persistenceConsent: requestOptimization.canPersistProfile, + locale, + data: pageResult.data, + }, + profileId: pageResult.data.profile.id, canPersistProfile: requestOptimization.canPersistProfile, } } -/** - * Resolve baselines against the SSR `selectedOptimizations`, then walk each - * rich-text field and substitute inline merge-tag entries using the SDK's - * `getMergeTagValue`. Shipping fully-resolved entries through `TransferState` - * lets JS-disabled clients see the variant content AND the personalised merge - * tags on first paint, not just the placeholder text. - * - * Nested entries (`fields.nested[]`) often appear only on the *resolved* - * variant rather than on the baseline, so we recurse after each - * `resolveOptimizedEntry` call to cover per-level Personalization - * assignments inside the chosen variant. - */ -function resolveServerEntries( - sdk: NodeContentfulOptimizationType, - baselines: readonly Entry[], - selectedOptimizations: OptimizationData['selectedOptimizations'], - profile: Profile | undefined, -): Record { - const resolved: Record = {} - const queue: Entry[] = [...baselines] - for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) { - if (Object.hasOwn(resolved, entry.sys.id)) continue - const result = sdk.resolveOptimizedEntry(entry, selectedOptimizations) - const entryWithMergeTags = profile - ? resolveEntryMergeTags(result.entry, (target) => sdk.getMergeTagValue(target, profile)) - : result.entry - resolved[entry.sys.id] = { ...result, entry: entryWithMergeTags } - const nested: unknown = entryWithMergeTags.fields.nested - if (Array.isArray(nested)) { - for (const child of nested) { - if (isResolvedContentfulEntry(child)) queue.push(child) - } - } - } - return resolved -} - function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string): void { const headers = responseInit.headers instanceof Headers @@ -351,28 +331,6 @@ function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string) responseInit.headers = headers } -function stampServerHandoff( - transferState: TransferState, - serverData: ServerOptimizationData, - baselines: readonly Entry[], - resolvedEntries: Record, -): void { - const handoff: ServerHandoff = serverData.consentGranted - ? { - consent: true, - profile: serverData.data.profile, - profileId: serverData.profileId, - selectedOptimizations: serverData.data.selectedOptimizations, - } - : { consent: false } - transferState.set(SERVER_OPTIMIZATION_KEY, handoff) - transferState.set>( - SERVER_BASELINES_KEY, - Object.fromEntries(baselines.map((baseline) => [baseline.sys.id, baseline])), - ) - transferState.set>(SERVER_RESOLVED_ENTRIES_KEY, resolvedEntries) -} - async function runServerPreflight(): Promise { const request = inject(REQUEST, { optional: true }) if (!request) return @@ -387,19 +345,17 @@ async function runServerPreflight(): Promise { const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] const baselines = await contentful.fetchEntries(baselineIds) - const serverData = await getServerOptimizationData(sdk, request, consentGranted, config.locale) + const outcome = await computeSnapshot(sdk, request, consentGranted, config.locale) - if (serverData.consentGranted && serverData.canPersistProfile && responseInit) { - persistAnonymousIdCookie(responseInit, serverData.profileId) + if (outcome.canPersistProfile && outcome.profileId && responseInit) { + persistAnonymousIdCookie(responseInit, outcome.profileId) } - const resolvedEntries = resolveServerEntries( - sdk, - baselines, - serverData.consentGranted ? serverData.data.selectedOptimizations : [], - serverData.consentGranted ? serverData.data.profile : undefined, + transferState.set(SERVER_OPTIMIZATION_KEY, outcome.snapshot) + transferState.set>( + SERVER_BASELINES_KEY, + Object.fromEntries(baselines.map((baseline) => [baseline.sys.id, baseline])), ) - stampServerHandoff(transferState, serverData, baselines, resolvedEntries) } /** diff --git a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts deleted file mode 100644 index a3d719e9..00000000 --- a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { makeStateKey, type StateKey } from '@angular/core' -import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import type { ResolvedData } from '@contentful/optimization-web/core-sdk' -import type { Entry, EntrySkeletonType } from 'contentful' - -export type ResolvedEntryData = ResolvedData - -/** - * Snapshot of the personalization context resolved server-side. Stamped into - * `TransferState` during SSR and read by browser code on hydration. The - * personalization fields are present only when consent was granted server-side - * and the preflight successfully fetched `OptimizationData`. - */ -export interface ServerHandoff { - readonly consent: boolean - readonly profile?: Profile - readonly profileId?: string - readonly selectedOptimizations?: SelectedOptimizationArray -} - -export const SERVER_OPTIMIZATION_KEY: StateKey = - makeStateKey('ssr-optimization') - -/** - * Server-resolved entries keyed by baseline entry id. The value is the raw - * `sdk.resolveOptimizedEntry()` output (`{ entry, selectedOptimization? }`) - * — no additional restructuring — so browser hydration can consume the SDK's - * native shape directly. - */ -export const SERVER_RESOLVED_ENTRIES_KEY: StateKey> = - makeStateKey>('ssr-resolved-entries') - -/** - * Baseline CDA entries keyed by id. Carried separately from the resolved - * payload so the browser can skip a duplicate CDA fetch on hydration without - * conflating the original entry with the resolved variant. - */ -export const SERVER_BASELINES_KEY: StateKey> = - makeStateKey>('ssr-baselines') diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx index 7eb29da7..8273fac8 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -1,6 +1,6 @@ import { createContext } from 'react' -import type { WebOptimizationRuntime } from '../runtime/webRuntime' +import type { WebOptimizationRuntime } from '@contentful/optimization-web/runtime' export type OptimizationSdk = WebOptimizationRuntime diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index 900d7f8f..e03406f7 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -19,8 +19,11 @@ import { type ReactElement, } from 'react' +import { + createWebSnapshotRuntime, + type WebOptimizationRuntime, +} from '@contentful/optimization-web/runtime' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' -import { createWebSnapshotRuntime, type WebOptimizationRuntime } from '../runtime/webRuntime' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. diff --git a/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts deleted file mode 100644 index e7a3b2a6..00000000 --- a/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type ContentfulOptimization from '@contentful/optimization-web' -import { - createSnapshotRuntime, - type OptimizationRuntime, - type OptimizationSnapshot, -} from '@contentful/optimization-web/runtime' - -type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' - -export interface WebOptimizationRuntime - extends OptimizationRuntime, Pick {} - -const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { - enable: () => undefined, - disable: () => undefined, - enableElement: () => undefined, - disableElement: () => undefined, - clearElement: () => undefined, -} - -export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOptimizationRuntime { - const runtime = createSnapshotRuntime(snapshot) - - return Object.assign(runtime, { - tracking: NOOP_TRACKING, - trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), - }) -} diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 0f8e846a..79087cb8 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -135,8 +135,8 @@ "index.mjs": 12500, "bridge-support.cjs": 1200, "bridge-support.mjs": 1200, - "runtime.cjs": 800, - "runtime.mjs": 200, + "runtime.cjs": 900, + "runtime.mjs": 320, "contentful-optimization-web-components.umd.js": 39000, "presentation.cjs": 3200, "presentation.mjs": 3200, diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index d75894f4..c16273c6 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -1,7 +1,79 @@ /** * Framework-neutral runtime contracts for browser and snapshot-backed presentation layers. * + * Re-exports the universal {@link OptimizationRuntime} / {@link createSnapshotRuntime} from + * `@contentful/optimization-core/runtime` and adds the web-only composition + * ({@link WebOptimizationRuntime} / {@link createWebSnapshotRuntime}) that stitches in + * browser-only imperative APIs (`tracking`, `trackCurrentPage`) so framework layers can + * bind a single runtime object across server rendering and browser hydration. + * * @packageDocumentation */ +import { + createSnapshotRuntime, + type OptimizationRuntime, + type OptimizationSnapshot, +} from '@contentful/optimization-core/runtime' +import type ContentfulOptimization from './ContentfulOptimization' + export * from '@contentful/optimization-core/runtime' + +/** + * Members of the live web SDK that go beyond the universal + * {@link OptimizationRuntime} surface: browser-only imperative APIs used inside + * effects (never during render). + * + * @internal + */ +type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' + +/** + * The single runtime object framework layers (React Web, Angular, etc.) can consume. + * + * @remarks + * Composes the universal {@link OptimizationRuntime} (pure resolvers, read `states`, event + * actions — safe on server and client) with the browser-only web SDK surface + * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} instance + * satisfies it by construction; a server / initial-render backing is produced by + * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops. + * + * Every member is safe to reference in any environment: render-time members behave + * correctly on the server, and effect-only members are no-ops there (the server never + * runs effects, so this only matters defensively). + * + * @public + */ +export interface WebOptimizationRuntime + extends OptimizationRuntime, Pick {} + +const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { + enable: () => undefined, + disable: () => undefined, + enableElement: () => undefined, + disableElement: () => undefined, + clearElement: () => undefined, +} + +/** + * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. + * + * @param snapshot - Server-resolved optimization state for the current request. + * @returns A runtime that resolves and reads from the snapshot, with browser-only + * tracking APIs as inert no-ops. + * + * @remarks + * Used by framework providers during server rendering and the initial client render, + * before the live SDK exists. Delegates the universal surface to + * {@link createSnapshotRuntime} and adds no-op `tracking` / `trackCurrentPage`. + * + * @public + */ +export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOptimizationRuntime { + const runtime = createSnapshotRuntime(snapshot) + + return Object.assign(runtime, { + tracking: NOOP_TRACKING, + trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), + }) +}