From cd018a81f254ac358020593bf9c9dcc694acf985 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Tue, 6 Oct 2026 17:58:49 +0200 Subject: [PATCH 01/29] refactor(api-client)!: scope preflight to profile mutations Remove constructor-level preflight configuration and retain preview only on per-request single-profile mutations. Evaluate preview responses without persisting mock profile state. --- lib/mocks/src/experience-handlers.ts | 13 ++-- .../experience/ExperienceApiClient.test.ts | 71 ++++++++++++++++++- .../src/experience/ExperienceApiClient.ts | 32 +++++---- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/lib/mocks/src/experience-handlers.ts b/lib/mocks/src/experience-handlers.ts index 929aba713..cc317fdc3 100644 --- a/lib/mocks/src/experience-handlers.ts +++ b/lib/mocks/src/experience-handlers.ts @@ -121,18 +121,19 @@ function hasIdentifyEvent(events: readonly ExperienceEvent[] | undefined): boole function getResponseBody( profileId?: string, events?: readonly ExperienceEvent[], + commit = true, ): ExperienceResponse { const fixtures = getLoadedFixtures() profileId ??= crypto.randomUUID() - knownProfileIds.add(profileId) + if (commit) knownProfileIds.add(profileId) const identified = identifiedState[profileId] ?? false let responseBody: ExperienceResponse = cloneDeep(fixtures.newVisitor) if (identified || hasIdentifyEvent(events)) { - identifiedState[profileId] = true + if (commit) identifiedState[profileId] = true responseBody = cloneDeep(fixtures.identifiedVisitor) } @@ -206,7 +207,9 @@ export function getHandlers( ) } - return HttpResponse.json(getResponseBody(undefined, parsedEvents.data), { + const commit = new URL(request.url).searchParams.get('type') !== 'preflight' + + return HttpResponse.json(getResponseBody(undefined, parsedEvents.data, commit), { headers: CORS_HEADERS, }) }, @@ -241,7 +244,9 @@ export function getHandlers( ) } - return HttpResponse.json(getResponseBody(profileId.toString(), parsedEvents.data), { + const commit = new URL(request.url).searchParams.get('type') !== 'preflight' + + return HttpResponse.json(getResponseBody(profileId.toString(), parsedEvents.data, commit), { headers: CORS_HEADERS, }) }, diff --git a/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts b/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts index f168495d4..5b7d83450 100644 --- a/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts +++ b/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts @@ -118,6 +118,63 @@ describe('ExperienceApiClient', () => { rs.restoreAllMocks() }) + describe('preflight transport', () => { + it.each([ + { method: 'createProfile', profileId: 'profile-id' }, + { method: 'updateProfile', profileId: 'profile-id' }, + { method: 'upsertProfile', profileId: 'profile-id' }, + { method: 'upsertProfile', profileId: '' }, + ] as const)( + 'enables preview only for the current $method call ($profileId)', + async ({ method, profileId }) => { + const requestedUrls: URL[] = [] + const captureMutation = ({ request }: { request: Request }): Response => { + requestedUrls.push(new URL(request.url)) + return HttpResponse.json({ + data: { profile: { id: 'profile-id' }, experiences: [], changes: [] }, + }) + } + + server.use( + http.post( + `${EXPERIENCE_BASE_URL}v3/spaces/:space/environments/:env/profiles`, + captureMutation, + ), + http.post( + `${EXPERIENCE_BASE_URL}v3/spaces/:space/environments/:env/profiles/:profileId`, + captureMutation, + ), + ) + + const client = makeClient({ locale: 'en-US' }) + const params = { events: [makeTrackEvent('paired-delivery')], profileId } + + await client[method](params, { preflight: true, locale: 'de-DE' }) + await client[method](params, { preflight: false }) + await client[method](params) + + expect(requestedUrls.map((url) => url.searchParams.get('type'))).toEqual([ + 'preflight', + null, + null, + ]) + expect(requestedUrls.map((url) => url.searchParams.get('locale'))).toEqual([ + 'de-DE', + 'en-US', + 'en-US', + ]) + const expectedPath = `/v3/spaces/${SPACE_ID}/environments/${ENVIRONMENT}/profiles${ + method !== 'createProfile' && profileId ? `/${profileId}` : '' + }` + expect(requestedUrls.map((url) => url.pathname)).toEqual([ + expectedPath, + expectedPath, + expectedPath, + ]) + }, + ) + }) + describe('getProfile', () => { it('throws on empty profile id', async () => { const client = makeClient() @@ -125,7 +182,13 @@ describe('ExperienceApiClient', () => { }) it('getProfile hits the correct URL with default environment and optional locale', async () => { - const requested: { space?: string; env?: string; id?: string; locale?: string | null } = {} + const requested: { + space?: string + env?: string + id?: string + locale?: string | null + type?: string | null + } = {} server.use( http.get( @@ -137,6 +200,7 @@ describe('ExperienceApiClient', () => { requested.env = env requested.id = id requested.locale = getLocaleParam(request.url) + requested.type = getParam(request.url) return HttpResponse.json({ data: { id } }, { status: 200 }) }, @@ -152,6 +216,7 @@ describe('ExperienceApiClient', () => { expect(requested.env).toBe(ENVIRONMENT) expect(requested.id).toBe('f0837d7dc6344c36a3a0a06c4cde754b') expect(requested.locale).toBeNull() + expect(requested.type).toBeNull() // with locale const profile2 = await client.getProfile('a19c3f54d2b84e37a93f6d1c0e5b7284', { @@ -160,6 +225,7 @@ describe('ExperienceApiClient', () => { expect(profile2).toBeDefined() expect(requested.id).toBe('a19c3f54d2b84e37a93f6d1c0e5b7284') expect(requested.locale).toBe('de-DE') + expect(requested.type).toBeNull() expect(mockLogger.info).toHaveBeenCalledWith( 'ApiClient:Experience', @@ -454,12 +520,14 @@ describe('ExperienceApiClient', () => { it('upsertManyProfiles posts to /events and defaults to application/json (plainText=false)', async () => { let content: string | null = null let anonymousId: string | undefined + let typeQuery: string | null = null server.use( http.post( `${EXPERIENCE_BASE_URL}v3/spaces/:space/environments/:env/events`, async ({ request }) => { content = getContent(request.headers) + typeQuery = getParam(request.url) const body = await request.json() if (typeof body === 'object' && body !== null && 'events' in body) { @@ -503,6 +571,7 @@ describe('ExperienceApiClient', () => { expect(Array.isArray(profiles)).toBe(true) expect(content).toBe('application/json') expect(anonymousId).toBe('f0837d7dc6344c36a3a0a06c4cde754b') + expect(typeQuery).toBeNull() }) it('throws when upsertManyProfiles is called with an empty event batch', async () => { diff --git a/packages/universal/api-client/src/experience/ExperienceApiClient.ts b/packages/universal/api-client/src/experience/ExperienceApiClient.ts index 0f96a3421..353f20b18 100644 --- a/packages/universal/api-client/src/experience/ExperienceApiClient.ts +++ b/packages/universal/api-client/src/experience/ExperienceApiClient.ts @@ -69,7 +69,14 @@ export interface ExperienceApiClientRequestOptions { * preflight requests, which can improve performance in browser environments. */ plainText?: boolean +} +/** + * Request options for single-profile mutations. + * + * @public + */ +export interface ExperienceApiClientProfileMutationRequestOptions extends ExperienceApiClientRequestOptions { /** * When `true`, instructs the API to aggregate a new profile state but not store it. * @@ -88,7 +95,7 @@ export interface ExperienceApiClientRequestOptions { interface ProfileMutationRequestOptions { url: string body: unknown - options: ExperienceApiClientRequestOptions + options: ExperienceApiClientProfileMutationRequestOptions } /** @@ -177,7 +184,6 @@ export default class ExperienceApiClient extends ApiClientBase { private readonly ip?: ExperienceApiClientRequestOptions['ip'] private locale?: ExperienceApiClientRequestOptions['locale'] private readonly plainText?: ExperienceApiClientRequestOptions['plainText'] - private readonly preflight?: ExperienceApiClientRequestOptions['preflight'] /** * Creates a new {@link ExperienceApiClient} instance. @@ -187,7 +193,7 @@ export default class ExperienceApiClient extends ApiClientBase { constructor(config: ExperienceApiClientConfig) { super('Experience', config) - const { baseUrl, enabledFeatures, ip, locale, plainText, preflight } = config + const { baseUrl, enabledFeatures, ip, locale, plainText } = config // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Set default for anything falsey this.baseUrl = baseUrl || EXPERIENCE_BASE_URL @@ -195,7 +201,6 @@ export default class ExperienceApiClient extends ApiClientBase { this.ip = ip this.locale = locale this.plainText = plainText - this.preflight = preflight } /** @@ -228,7 +233,7 @@ export default class ExperienceApiClient extends ApiClientBase { */ public async getProfile( id: string, - options: Omit = {}, + options: Omit = {}, ): Promise { if (!id) throw new Error('Valid profile ID required.') @@ -240,7 +245,7 @@ export default class ExperienceApiClient extends ApiClientBase { const response = await this.fetch( this.constructUrl( `v3/spaces/${this.spaceId}/environments/${this.environment}/profiles/${id}`, - options, + { locale: options.locale }, ), { method: 'GET', @@ -303,7 +308,7 @@ export default class ExperienceApiClient extends ApiClientBase { */ public async createProfile( { events }: CreateProfileParams, - options: ExperienceApiClientRequestOptions = {}, + options: ExperienceApiClientProfileMutationRequestOptions = {}, ): Promise { const requestName = 'Create Profile' @@ -356,7 +361,7 @@ export default class ExperienceApiClient extends ApiClientBase { */ public async updateProfile( { profileId, events }: UpdateProfileParams, - options: ExperienceApiClientRequestOptions = {}, + options: ExperienceApiClientProfileMutationRequestOptions = {}, ): Promise { if (!profileId) throw new Error('Valid profile ID required.') @@ -409,7 +414,7 @@ export default class ExperienceApiClient extends ApiClientBase { */ public async upsertProfile( { profileId, events }: UpsertProfileParams, - options?: ExperienceApiClientRequestOptions, + options?: ExperienceApiClientProfileMutationRequestOptions, ): Promise { if (!profileId) { return await this.createProfile({ events }, options) @@ -461,7 +466,7 @@ export default class ExperienceApiClient extends ApiClientBase { const response = await this.makeProfileMutationRequest({ url: `v3/spaces/${this.spaceId}/environments/${this.environment}/events`, body, - options: { plainText: false, ...options }, + options: { plainText: false, ...options, preflight: false }, }) const { @@ -487,10 +492,13 @@ export default class ExperienceApiClient extends ApiClientBase { * * @internal */ - private constructUrl(path: string, options: ExperienceApiClientRequestOptions): string { + private constructUrl( + path: string, + options: ExperienceApiClientProfileMutationRequestOptions, + ): string { const url = new URL(path, this.baseUrl) const locale = options.locale ?? this.locale - const preflight = options.preflight ?? this.preflight + const { preflight } = options if (locale) { url.searchParams.set('locale', locale) From 01aad4eaf11c145714913b3d33739fa7e4ffd354 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Tue, 6 Oct 2026 21:34:23 +0200 Subject: [PATCH 02/29] feat(core)!: add paired preview and replay preparation Prepare request handoffs with API-issued profile IDs and retain atomic replay batches in the existing Experience queue. Share reset invalidation across stateful queues and preserve events appended during delivery. Remove ordinary preflight controls, record the accepted phase plan, and apply the approved measured-size budget formula. --- packages/universal/core-sdk/package.json | 10 +- .../universal/core-sdk/src/CoreApiConfig.ts | 2 - .../core-sdk/src/CoreStateful.handoff.test.ts | 336 ++++++++++++++++++ .../universal/core-sdk/src/CoreStateful.ts | 17 +- .../core-sdk/src/CoreStatefulEventEmitter.ts | 29 +- .../core-sdk/src/CoreStateless.test.ts | 16 +- .../universal/core-sdk/src/CoreStateless.ts | 2 +- .../src/CoreStatelessRequest.handoff.test.ts | 172 +++++++++ .../core-sdk/src/CoreStatelessRequest.ts | 119 ++++++- .../core-sdk/src/events/preparedEvents.ts | 33 ++ .../universal/core-sdk/src/handoff.test.ts | 26 +- packages/universal/core-sdk/src/handoff.ts | 24 +- .../src/queues/ExperienceQueue.test.ts | 2 +- .../core-sdk/src/queues/ExperienceQueue.ts | 188 ++++++---- .../core-sdk/src/queues/InsightsQueue.ts | 56 ++- .../state/applyOptimizationDataToSignals.ts | 2 + 16 files changed, 907 insertions(+), 127 deletions(-) create mode 100644 packages/universal/core-sdk/src/CoreStateful.handoff.test.ts create mode 100644 packages/universal/core-sdk/src/CoreStatelessRequest.handoff.test.ts create mode 100644 packages/universal/core-sdk/src/events/preparedEvents.ts diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index f959243e6..ec6f66a0f 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -113,16 +113,16 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 23200, - "index.mjs": 23100, + "index.cjs": 24700, + "index.mjs": 24000, "bridge-support.cjs": 1000, "bridge-support.mjs": 800, "runtime.cjs": 3700, - "runtime.mjs": 4900, + "runtime.mjs": 4100, "entry-source.cjs": 2100, "entry-source.mjs": 1900, - "preview-support.cjs": 5200, - "preview-support.mjs": 5100 + "preview-support.cjs": 5300, + "preview-support.mjs": 4700 } } }, diff --git a/packages/universal/core-sdk/src/CoreApiConfig.ts b/packages/universal/core-sdk/src/CoreApiConfig.ts index d51119676..1b4205a55 100644 --- a/packages/universal/core-sdk/src/CoreApiConfig.ts +++ b/packages/universal/core-sdk/src/CoreApiConfig.ts @@ -27,8 +27,6 @@ export interface CoreStatefulApiConfig extends CoreSharedApiConfig { ip?: ExperienceApiClientConfig['ip'] /** Experience API plain-text request toggle. */ plainText?: ExperienceApiClientConfig['plainText'] - /** Experience API preflight request toggle. */ - preflight?: ExperienceApiClientConfig['preflight'] } /** diff --git a/packages/universal/core-sdk/src/CoreStateful.handoff.test.ts b/packages/universal/core-sdk/src/CoreStateful.handoff.test.ts new file mode 100644 index 000000000..b7e749a68 --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStateful.handoff.test.ts @@ -0,0 +1,336 @@ +import type { ExperienceEventArray, OptimizationData } from './api-schemas' +import type { LifecycleInterceptors } from './CoreBase' +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import { EventBuilder, type EventEmissionResult } from './events' +import { signals } from './signals' +import { profile } from './test/fixtures/profile' + +const DATA: OptimizationData = { profile, changes: [], selectedOptimizations: [] } +const builder = new EventBuilder({ channel: 'server', library: { name: 'test', version: '1' } }) +const batch = (): ExperienceEventArray => [ + builder.buildIdentify({ userId: 'customer' }), + builder.buildTrack({ event: 'campaign' }), + builder.buildPageView(), +] + +class PairedCore extends CoreStateful { + async commit( + events: ExperienceEventArray, + profileId?: string, + locale?: string, + ): Promise { + return await this.sendPreparedExperienceEvents(events, profileId, locale) + } + + setOnline(value: boolean): void { + this.online = value + } +} + +let core: PairedCore | undefined +function initialize(options: Partial = {}): PairedCore { + core = new PairedCore({ spaceId: 'key_123', defaults: { consent: true }, ...options }) + rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue(DATA) + rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + return core +} + +describe('prepared Core delivery', () => { + beforeEach(() => { + rs.useFakeTimers() + signals.online.value = true + signals.profile.value = undefined + }) + afterEach(() => { + core?.consent(false) + core?.destroy() + core = undefined + rs.clearAllTimers() + rs.useRealTimers() + }) + + it('commits one batch without rerunning event transformations', async () => { + const runtime = initialize() + const intercept = rs.fn[0]>((event) => event) + runtime.interceptors.event.add(intercept) + const events = batch() + const result = await runtime.commit(events, 'continuation', 'de-DE') + expect(result).toEqual({ accepted: true }) + await rs.advanceTimersByTimeAsync(0) + expect(runtime.api.experience.upsertProfile).toHaveBeenCalledTimes(1) + expect(runtime.api.experience.upsertProfile).toHaveBeenCalledWith( + { + profileId: 'continuation', + events: events.map((event) => ({ + ...event, + context: { ...event.context, gdpr: { isConsentGiven: true } }, + })), + }, + { preflight: false, locale: 'de-DE' }, + ) + expect(intercept).not.toHaveBeenCalled() + expect(runtime.states.profile.current).toEqual(profile) + }) + + it('retains an ambiguous first failure and retries the same events/identity/locale under backoff', async () => { + const runtime = initialize({ + queuePolicy: { flush: { baseBackoffMs: 50, maxBackoffMs: 50, jitterRatio: 0 } }, + }) + const upsert = rs + .spyOn(runtime.api.experience, 'upsertProfile') + .mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValue(DATA) + const events = batch() + await expect(runtime.commit(events, 'continuation', 'de-DE')).resolves.toEqual({ + accepted: true, + }) + runtime.setLocale('fr-FR') + await rs.advanceTimersByTimeAsync(50) + expect(upsert).toHaveBeenCalledTimes(2) + expect(upsert.mock.calls[1]).toEqual(upsert.mock.calls[0]) + expect(runtime.states.profile.current).toEqual(profile) + }) + + it('retains distinct offline batches without mixing or losing their page-bearing prefixes', async () => { + const runtime = initialize() + runtime.setOnline(false) + const first = batch() + const second = batch() + await expect(runtime.commit(first)).resolves.toEqual({ accepted: true }) + await expect(runtime.commit(second)).resolves.toEqual({ accepted: true }) + expect(runtime.api.experience.upsertProfile).not.toHaveBeenCalled() + runtime.setOnline(true) + await rs.advanceTimersByTimeAsync(0) + expect(runtime.api.experience.upsertProfile).toHaveBeenCalledTimes(2) + const calls = rs.mocked(runtime.api.experience.upsertProfile).mock.calls + expect(calls.map(([payload]) => payload.events.map(({ messageId }) => messageId))).toEqual([ + first.map(({ messageId }) => messageId), + second.map(({ messageId }) => messageId), + ]) + }) + + it('rejects a whole batch at capacity and never admits a truncated prefix', async () => { + const runtime = initialize({ queuePolicy: { offlineMaxEvents: 4 } }) + runtime.setOnline(false) + await runtime.commit(batch()) + await expect(runtime.commit(batch())).resolves.toEqual({ accepted: false }) + runtime.setOnline(true) + await rs.advanceTimersByTimeAsync(0) + expect(runtime.api.experience.upsertProfile).toHaveBeenCalledTimes(1) + }) + + it('drains a distinct handoff admitted while the earlier delivery is pending', async () => { + const runtime = initialize() + let finish: ((value: OptimizationData) => void) | undefined + const upsert = rs + .spyOn(runtime.api.experience, 'upsertProfile') + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + finish = resolve + }), + ) + .mockResolvedValue(DATA) + const first = batch() + const second = batch() + const pending = runtime.commit(first) + await expect(runtime.commit(second)).resolves.toEqual({ accepted: true }) + expect(upsert).toHaveBeenCalledTimes(1) + finish?.(DATA) + await pending + await rs.advanceTimersByTimeAsync(0) + expect(upsert).toHaveBeenCalledTimes(2) + expect( + upsert.mock.calls.map(([payload]) => payload.events.map(({ messageId }) => messageId)), + ).toEqual([first.map(({ messageId }) => messageId), second.map(({ messageId }) => messageId)]) + }) + + it('reevaluates current consent before admission, without freezing a denied attempt', async () => { + const runtime = initialize({ defaults: { consent: false } }) + const events = batch() + await expect(runtime.commit(events)).resolves.toEqual({ accepted: false }) + expect(runtime.api.experience.upsertProfile).not.toHaveBeenCalled() + runtime.consent({ events: true, persistence: false }) + await expect(runtime.commit(events)).resolves.toMatchObject({ accepted: true }) + expect(runtime.states.persistenceConsent.current).toBe(false) + }) + + it('does not restore profile state after reset while a response interceptor is pending', async () => { + const runtime = initialize() + let release: (() => void) | undefined + let started: (() => void) | undefined + const intercepted = new Promise((resolve) => { + started = resolve + }) + runtime.interceptors.state.add(async (state) => { + started?.() + await new Promise((resolve) => { + release = resolve + }) + return state + }) + const delivery = runtime.commit(batch()) + await intercepted + runtime.reset() + release?.() + await delivery + await rs.advanceTimersByTimeAsync(0) + expect(runtime.states.profile.current).toBeUndefined() + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'idle' }) + }) + + it('ignores a pre-reset API response without replacing state established after reset', async () => { + const runtime = initialize() + let finish: ((value: OptimizationData) => void) | undefined + rs.spyOn(runtime.api.experience, 'upsertProfile') + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + finish = resolve + }), + ) + .mockResolvedValueOnce({ ...DATA, profile: { ...profile, traits: { current: true } } }) + const pending = runtime.commit(batch()) + runtime.reset() + await runtime.page() + finish?.(DATA) + await expect(pending).resolves.toEqual({ accepted: true }) + await rs.advanceTimersByTimeAsync(0) + expect(runtime.states.profile.current?.traits).toEqual({ current: true }) + }) + + it('does not publish or schedule queue retries for an in-flight failure after reset', async () => { + const runtime = initialize() + let fail: ((error: Error) => void) | undefined + const upsert = rs.spyOn(runtime.api.experience, 'upsertProfile').mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + fail = reject + }), + ) + const pending = runtime.commit(batch()) + runtime.reset() + fail?.(new Error('old request failed')) + await expect(pending).resolves.toEqual({ accepted: true }) + await rs.advanceTimersByTimeAsync(10_000) + expect(upsert).toHaveBeenCalledTimes(1) + expect(runtime.states.profile.current).toBeUndefined() + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'idle' }) + }) + + it('commits an ID-less replay through the API and uses the returned ID for the next batch', async () => { + const runtime = initialize() + runtime.setOnline(false) + const first = batch() + const second = batch() + await runtime.commit(first) + await runtime.commit(second) + runtime.setOnline(true) + await rs.advanceTimersByTimeAsync(0) + const calls = rs.mocked(runtime.api.experience.upsertProfile).mock.calls + expect(calls).toHaveLength(2) + expect(calls[0]?.[0].profileId).toBeUndefined() + expect(calls[1]?.[0].profileId).toBe(DATA.profile.id) + expect(calls.map(([payload]) => payload.events.map(({ messageId }) => messageId))).toEqual([ + first.map(({ messageId }) => messageId), + second.map(({ messageId }) => messageId), + ]) + expect(runtime.states.profile.current).toEqual(DATA.profile) + }) + + it('keeps current Insights work intact when a pre-reset flush completes', async () => { + const runtime = initialize({ getAnonymousId: () => profile.id }) + let finishOld: ((value: boolean) => void) | undefined + let finishNew: ((value: boolean) => void) | undefined + const insights = rs + .spyOn(runtime.api.insights, 'sendBatchEvents') + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishOld = resolve + }), + ) + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishNew = resolve + }), + ) + await runtime.trackClick({ componentId: 'before-reset' }) + const oldFlush = runtime.flush() + runtime.reset() + await runtime.trackClick({ componentId: 'after-reset' }) + const newFlush = runtime.flush() + finishOld?.(true) + await oldFlush + await runtime.flush() + expect(insights).toHaveBeenCalledTimes(2) + finishNew?.(true) + await newFlush + await runtime.flush() + expect(insights).toHaveBeenCalledTimes(2) + expect(insights.mock.calls[1]?.[0][0]?.events).toMatchObject([{ componentId: 'after-reset' }]) + }) + + it('retains an ordinary event admitted at capacity while an earlier flush is pending', async () => { + const runtime = initialize({ queuePolicy: { offlineMaxEvents: 2 } }) + let finish: ((value: OptimizationData) => void) | undefined + const upsert = rs + .spyOn(runtime.api.experience, 'upsertProfile') + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + finish = resolve + }), + ) + .mockResolvedValue(DATA) + runtime.setOnline(false) + await runtime.track({ event: 'first' }) + await runtime.track({ event: 'second' }) + runtime.setOnline(true) + runtime.setOnline(false) + await runtime.track({ event: 'third' }) + finish?.(DATA) + await rs.advanceTimersByTimeAsync(0) + expect( + upsert.mock.calls + .flatMap(([payload]) => payload.events) + .filter((event) => event.type === 'track') + .map((event) => event.event), + ).toEqual(['first', 'second', 'third']) + }) + + it('queues early Insights interactions using known continuity and retains interactions admitted during flush', async () => { + const runtime = initialize({ getAnonymousId: () => 'continuation' }) + let finish: ((value: boolean) => void) | undefined + const insights = rs + .spyOn(runtime.api.insights, 'sendBatchEvents') + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + finish = resolve + }), + ) + .mockResolvedValue(true) + await runtime.trackClick({ + componentId: 'displayed-entry', + experienceId: 'experience', + variantIndex: 1, + }) + const flushing = runtime.flush() + await runtime.trackClick({ componentId: 'second-entry' }) + finish?.(true) + await flushing + await runtime.flush() + expect(insights).toHaveBeenCalledTimes(2) + expect(insights.mock.calls[0]?.[0][0]).toMatchObject({ + profile: { id: 'continuation' }, + events: [{ componentId: 'displayed-entry', experienceId: 'experience', variantIndex: 1 }], + }) + expect(insights.mock.calls[1]?.[0][0]).toMatchObject({ + profile: { id: 'continuation' }, + events: [{ componentId: 'second-entry' }], + }) + expect(runtime.states.profile.current).toBeUndefined() + }) +}) diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index 38310a669..8da12369a 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -82,7 +82,6 @@ const createStatefulExperienceApiConfig = ( ip: api?.ip, locale, plainText: api?.plainText, - preflight: api?.preflight, } return hasDefinedValues(experienceConfig) ? experienceConfig : undefined @@ -208,6 +207,7 @@ let statefulInstanceCounter = 0 class CoreStateful extends CoreStatefulEventEmitter implements ConsentController, ConsentGuard { private readonly singletonOwner: string private destroyed = false + private resetToken = {} protected readonly allowedEventTypes: AllowedEventType[] protected readonly experienceQueue: ExperienceQueue protected readonly insightsQueue: InsightsQueue @@ -271,9 +271,12 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController this.allowedEventTypes = allowedEventTypes ?? DEFAULT_ALLOWED_EVENT_TYPES this.onEventBlocked = onEventBlocked localeSignal.value = locale + const getResetToken = (): object | undefined => (this.destroyed ? undefined : this.resetToken) this.insightsQueue = new InsightsQueue({ eventInterceptors: this.interceptors.event, flushPolicy: resolvedQueuePolicy.flush, + getAnonymousId, + getResetToken, insightsApi: this.api.insights, }) this.experienceQueue = new ExperienceQueue({ @@ -281,6 +284,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController eventInterceptors: this.interceptors.event, flushPolicy: resolvedQueuePolicy.flush, getAnonymousId: getAnonymousId ?? (() => undefined), + getResetToken, offlineMaxEvents: resolvedQueuePolicy.offlineMaxEvents, onOfflineDrop: resolvedQueuePolicy.onOfflineDrop, stateInterceptors: this.interceptors.state, @@ -329,11 +333,12 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController }) effect(() => { - if (!onlineSignal.value) return + if (this.destroyed || !onlineSignal.value) return this.insightsQueue.clearScheduledRetry() this.experienceQueue.clearScheduledRetry() - void this.flushQueues({ force: true }) + void this.insightsQueue.flush({ force: true }) + void this.experienceQueue.flush({ force: true }) }) } @@ -345,6 +350,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController } private clearQueuedEvents(): void { + this.resetToken = {} this.insightsQueue.clearQueuedEvents() this.experienceQueue.clearQueuedEvents() } @@ -435,7 +441,6 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController destroy(): void { if (this.destroyed) return - this.destroyed = true this.optimizationContexts.clear() void this.insightsQueue.flush({ force: true }).catch((error: unknown) => { logger.warn('Failed to flush insights queue during destroy()', String(error)) @@ -443,12 +448,16 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController void this.experienceQueue.flush({ force: true }).catch((error: unknown) => { logger.warn('Failed to flush Experience queue during destroy()', String(error)) }) + this.destroyed = true this.insightsQueue.clearPeriodicFlushTimer() + this.insightsQueue.clearScheduledRetry() + this.experienceQueue.clearScheduledRetry() releaseStatefulRuntimeSingleton(this.singletonOwner) } reset(): void { + this.clearQueuedEvents() this.optimizationContexts.clear() batch(() => { blockedEventSignal.value = undefined diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index ae83abe58..6252ce22e 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -28,6 +28,7 @@ import type { TrackBuilderArgs, ViewBuilderArgs, } from './events' +import { validatePreparedEvents, withEventConsent } from './events/preparedEvents' import type { ExperienceQueue } from './queues/ExperienceQueue' import type { InsightsQueue } from './queues/InsightsQueue' import { @@ -293,7 +294,7 @@ abstract class CoreStatefulEventEmitter return hasEventConsent(name, consentSignal.value, this.allowedEventTypes) } - private onBlockedByConsent(name: string, args: readonly unknown[]): void { + protected onBlockedByConsent(name: string, args: readonly unknown[]): void { coreLogger.warn( `Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(args)}`, ) @@ -317,6 +318,32 @@ abstract class CoreStatefulEventEmitter return { accepted: true, data } } + protected async sendPreparedExperienceEvents( + events: readonly ExperienceEventPayload[], + profileId?: string, + locale?: string, + ): Promise { + let validEvents: ExperienceEventPayload[] = [] + try { + validEvents = validatePreparedEvents(events) + } catch (error) { + coreLogger.warn('Invalid prepared Experience events; delivery was not admitted.', error) + return { accepted: false } + } + for (const event of validEvents) { + if (!this.hasConsent(event.type)) { + this.onBlockedByConsent(event.type, [event]) + return { accepted: false } + } + } + return await Promise.resolve( + this.experienceQueue.sendPrepared( + validEvents.map((event) => withEventConsent(event, consentSignal.value === true)), + { profileId, locale }, + ), + ) + } + protected async sendInsightsEvent( method: string, args: readonly unknown[], diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts index 36ab03c92..1519bace1 100644 --- a/packages/universal/core-sdk/src/CoreStateless.test.ts +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -114,7 +114,6 @@ describe('CoreStateless', () => { ip: '198.51.100.5', locale: 'de-DE', plainText: false, - preflight: true, }, }, ]) @@ -126,7 +125,6 @@ describe('CoreStateless', () => { expect(Reflect.get(core.api.experience, 'ip')).toBeUndefined() expect(Reflect.get(core.api.experience, 'locale')).toBeUndefined() expect(Reflect.get(core.api.experience, 'plainText')).toBeUndefined() - expect(Reflect.get(core.api.experience, 'preflight')).toBeUndefined() }) it('exposes the configured top-level SDK locale', () => { @@ -159,7 +157,6 @@ describe('CoreStateless', () => { ip: '203.0.113.10', locale: 'de-DE', plainText: false, - preflight: true, } const requestOptimization = core.forRequest({ consent: { events: true, persistence: true }, @@ -272,7 +269,7 @@ describe('CoreStateless', () => { const requestOptimization = core.forRequest({ consent: true, eventContext: { locale: 'en-US' }, - experienceOptions: { locale: 'fr-FR', preflight: true }, + experienceOptions: { locale: 'fr-FR' }, locale: ' de_DE ', profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) @@ -286,7 +283,7 @@ describe('CoreStateless', () => { expect.objectContaining({ context: expect.objectContaining({ locale: 'de-DE' }) }), ], }), - expect.objectContaining({ locale: 'de-DE', preflight: true }), + expect.objectContaining({ locale: 'de-DE' }), ) }) @@ -593,7 +590,7 @@ describe('CoreStateless', () => { profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) - await requestOptimization.track({ event: 'server-preflight' }) + await requestOptimization.track({ event: 'server-event' }) expect(upsertProfile.mock.calls[0]?.[0].events[0]?.context.gdpr.isConsentGiven).toBe(false) }) @@ -702,7 +699,6 @@ describe('CoreStateless', () => { const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) const requestOptimization = core.forRequest({ consent: true, - experienceOptions: { preflight: true }, profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) @@ -718,7 +714,7 @@ describe('CoreStateless', () => { profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', events: [expect.objectContaining({ type: 'component' })], }), - expect.objectContaining({ preflight: true }), + undefined, ) expect(sendBatchEvents).toHaveBeenCalledWith([ { @@ -824,7 +820,6 @@ describe('CoreStateless', () => { const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) const requestOptimization = core.forRequest({ consent: true, - experienceOptions: { preflight: true }, profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) @@ -881,7 +876,6 @@ describe('CoreStateless', () => { const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) const requestOptimization = core.forRequest({ consent: true, - experienceOptions: { preflight: true }, }) const result = await requestOptimization.trackView({ @@ -903,7 +897,7 @@ describe('CoreStateless', () => { profileId: undefined, events: [expect.objectContaining({ type: 'component' })], }), - expect.objectContaining({ preflight: true }), + undefined, ) expect(sendBatchEvents).toHaveBeenCalledWith([ { diff --git a/packages/universal/core-sdk/src/CoreStateless.ts b/packages/universal/core-sdk/src/CoreStateless.ts index 7977eb577..6a1ce38ea 100644 --- a/packages/universal/core-sdk/src/CoreStateless.ts +++ b/packages/universal/core-sdk/src/CoreStateless.ts @@ -21,7 +21,7 @@ import { normalizeExplicitLocale } from './locale' */ export interface CoreStatelessRequestOptions extends Pick< ExperienceApiClientRequestOptions, - 'ip' | 'locale' | 'plainText' | 'preflight' + 'ip' | 'locale' | 'plainText' > {} /** diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.handoff.test.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.handoff.test.ts new file mode 100644 index 000000000..21fab1cd2 --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStatelessRequest.handoff.test.ts @@ -0,0 +1,172 @@ +import type { OptimizationData } from './api-schemas' +import CoreStateless from './CoreStateless' +import type { InitialExperienceEvent } from './CoreStatelessRequest' +import { profile } from './test/fixtures/profile' +import { selectedOptimizations } from './test/fixtures/selectedOptimizations' + +const DATA: OptimizationData = { profile, selectedOptimizations, changes: [] } + +describe('request handoff preparation', () => { + it('previews the ordered transformed events under the continuation, retaining linked response data', async () => { + const core = new CoreStateless({ spaceId: 'key_123', locale: 'en-US' }) + const returned = { ...DATA, profile: { ...profile, id: 'linked-profile' } } + const preview = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue(returned) + const intercepted: string[] = [] + core.interceptors.event.add((event) => { + intercepted.push(event.type) + return event.type === 'track' + ? { ...event, properties: { ...event.properties, prepared: true } } + : event + }) + const request = core.forRequest({ + consent: { events: true, persistence: true }, + profile: { id: 'continuation' }, + locale: 'de-DE', + eventContext: { userAgent: 'server-agent' }, + experienceOptions: { plainText: false, ip: '203.0.113.10' }, + }) + + const { handoff, data } = await request.prepareRequestHandoff({ + routeKey: '/products?campaign=one', + initialEvents: [ + { type: 'identify', userId: 'customer' }, + { type: 'track', event: 'campaign' }, + ], + page: { properties: { url: 'https://analytics.test/override' } }, + }) + + expect(handoff.profileId).toBe('continuation') + expect(handoff.replay?.routeKey).toBe('/products?campaign=one') + expect(handoff.replay?.locale).toBe('de-DE') + expect(handoff.replay?.events.map(({ type }) => type)).toEqual(['identify', 'track', 'page']) + expect(intercepted).toEqual(['identify', 'track', 'page']) + expect(preview).toHaveBeenCalledWith( + { profileId: 'continuation', events: handoff.replay?.events }, + { preflight: true, plainText: false, ip: '203.0.113.10', locale: 'de-DE' }, + ) + expect(handoff.replay?.events[1]).toMatchObject({ properties: { prepared: true } }) + expect(handoff.replay?.events[0]).toMatchObject({ + context: { userAgent: 'server-agent', locale: 'de-DE', gdpr: { isConsentGiven: true } }, + }) + expect(data).toBe(returned) + expect(request.profile).toBe(returned.profile) + expect(request.canPersistProfile).toBe(true) + expect(handoff.state).toBeUndefined() + }) + + it('uses the profile ID returned by preview when the request has no known ID', async () => { + const core = new CoreStateless({ spaceId: 'key_123' }) + const preview = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue(DATA) + const request = core.forRequest({ consent: true }) + expect(request.profile).toBeUndefined() + const result = await request.prepareRequestHandoff({ routeKey: '/landing' }) + expect(preview).toHaveBeenCalledWith( + { profileId: undefined, events: result.handoff.replay?.events }, + expect.objectContaining({ preflight: true }), + ) + expect(result.handoff.profileId).toBe(DATA.profile.id) + expect(request.profile).toBe(DATA.profile) + }) + + it.each([undefined, profile.id])( + 'retains valid events and only a known ID after preview failure (%s)', + async (profileId) => { + const core = new CoreStateless({ spaceId: 'key_123' }) + const preview = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockRejectedValue(new Error('preview unavailable')) + const request = core.forRequest({ + consent: { events: true, persistence: true }, + profile: profileId === undefined ? undefined : { id: profileId }, + }) + const result = await request.prepareRequestHandoff({ routeKey: '/landing' }) + expect(result.data).toBeUndefined() + expect(result.handoff.profileId).toBe(profileId) + expect(request.profile?.id).toBe(profileId) + expect(result.handoff.replay?.events).toHaveLength(1) + expect(preview).toHaveBeenCalledWith( + { profileId, events: result.handoff.replay?.events }, + expect.objectContaining({ preflight: true }), + ) + }, + ) + + it('uses the API response profile for later request operations without rewriting linked response data', async () => { + const core = new CoreStateless({ spaceId: 'key_123' }) + const preview = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue(DATA) + const request = core.forRequest({ consent: true, profile: { id: 'continuation' } }) + const first = await request.prepareRequestHandoff({ routeKey: '/one' }) + const second = await request.prepareRequestHandoff({ routeKey: '/two' }) + expect(first.handoff.profileId).toBe('continuation') + expect(second.handoff.profileId).toBe(DATA.profile.id) + expect(preview.mock.calls.map(([payload]) => payload.profileId)).toEqual([ + 'continuation', + DATA.profile.id, + ]) + expect(first.handoff.replay?.events[0]?.messageId).not.toBe( + second.handoff.replay?.events[0]?.messageId, + ) + }) + + it('keeps persistence permission independent of a consent-blocked page without inventing an ID', async () => { + const core = new CoreStateless({ spaceId: 'key_123' }) + const preview = rs.spyOn(core.api.experience, 'upsertProfile') + const request = core.forRequest({ consent: { events: false, persistence: true } }) + const { handoff } = await request.prepareRequestHandoff({ routeKey: '/' }) + expect(handoff.profileId).toBeUndefined() + expect(handoff.replay).toBeUndefined() + expect(request.canPersistProfile).toBe(true) + expect(preview).not.toHaveBeenCalled() + }) + + it('prepares only consent-permitted inputs and stamps the actual consent', async () => { + const core = new CoreStateless({ spaceId: 'key_123', allowedEventTypes: ['page', 'track'] }) + const preview = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue(DATA) + const request = core.forRequest({ consent: false }) + const { handoff } = await request.prepareRequestHandoff({ + routeKey: '/', + initialEvents: [ + { type: 'identify', userId: 'customer' }, + { type: 'track', event: 'allowed' }, + ], + }) + expect(handoff.replay?.events.map(({ type }) => type)).toEqual(['track', 'page']) + expect(handoff.replay?.events.every((event) => !event.context.gdpr.isConsentGiven)).toBe(true) + expect(preview).toHaveBeenCalledTimes(1) + }) + + it.each([ + { type: 'track', count: 200 }, + { type: 'identify', count: 51 }, + ] as const)( + 'rejects an oversized $type prefix without producing a replay', + async ({ type, count }) => { + const core = new CoreStateless({ spaceId: 'key_123' }) + const preview = rs.spyOn(core.api.experience, 'upsertProfile') + const initialEvents: InitialExperienceEvent[] = Array.from({ length: count }, () => + type === 'identify' ? { type, userId: 'customer' } : { type, event: 'track' }, + ) + const { handoff } = await core + .forRequest({ consent: true }) + .prepareRequestHandoff({ routeKey: '/', initialEvents }) + expect(handoff.replay).toBeUndefined() + expect(preview).not.toHaveBeenCalled() + }, + ) + + it('rejects invalid route/event inputs before preview', async () => { + const core = new CoreStateless({ spaceId: 'key_123' }) + const preview = rs.spyOn(core.api.experience, 'upsertProfile') + const request = core.forRequest({ consent: true, profile: { id: 'continuation' } }) + const invalid: unknown = await Reflect.apply(request.prepareRequestHandoff, request, [ + { routeKey: '/', initialEvents: [{ type: 'page' }] }, + ]) + expect(invalid).toEqual({ + handoff: { cache: { scope: 'private-request' }, profileId: 'continuation' }, + }) + expect( + (await request.prepareRequestHandoff({ routeKey: 'https://example.test/' })).handoff.replay, + ).toBeUndefined() + expect(preview).not.toHaveBeenCalled() + }) +}) diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.ts index 4ba4464a1..46cf56db5 100644 --- a/packages/universal/core-sdk/src/CoreStatelessRequest.ts +++ b/packages/universal/core-sdk/src/CoreStatelessRequest.ts @@ -31,6 +31,8 @@ import type { UniversalEventBuilderArgs, ViewBuilderArgs, } from './events' +import { validatePreparedEvents, withEventConsent } from './events/preparedEvents' +import type { OptimizationHandoff } from './handoff' import { normalizeExplicitLocale } from './locale' import { createManagedEntryHandoffs, normalizeManagedEntryDescriptor } from './managed-entry' @@ -129,22 +131,23 @@ const requireInsightsProfile = ( throw new Error(errorMessage) } -const withRequestEventConsent = ( - event: TEvent, - isConsentGiven: boolean, -): TEvent => ({ - ...event, - context: { - ...event.context, - gdpr: { - ...event.context.gdpr, - isConsentGiven, - }, - }, -}) - type RequestExperienceMethod = 'identify' | 'page' | 'screen' | 'track' +export type InitialExperienceEvent = + | ({ readonly type: 'identify' } & IdentifyBuilderArgs) + | ({ readonly type: 'track' } & TrackBuilderArgs) + +export interface PrepareRequestHandoffOptions { + readonly routeKey: string + readonly initialEvents?: readonly InitialExperienceEvent[] + readonly page?: PageViewBuilderArgs +} + +export interface PrepareRequestHandoffResult { + readonly handoff: OptimizationHandoff + readonly data?: OptimizationData +} + /** * Request-bound stateless Optimization Core event client. * @@ -176,9 +179,15 @@ export class CoreStatelessRequest { ? (eventContext ?? {}) : { ...eventContext, locale: requestLocale } this.experienceOptions = - requestLocale === undefined - ? experienceOptions - : { ...experienceOptions, locale: requestLocale } + experienceOptions === undefined + ? undefined + : { + ip: experienceOptions.ip, + plainText: experienceOptions.plainText, + locale: experienceOptions.locale, + } + if (requestLocale !== undefined) + this.experienceOptions = { ...this.experienceOptions, locale: requestLocale } this.insightsOptions = insightsOptions } @@ -189,6 +198,78 @@ export class CoreStatelessRequest { return this.currentProfile } + async prepareRequestHandoff({ + routeKey, + initialEvents, + page, + }: PrepareRequestHandoffOptions): Promise { + const profileId = this.currentProfile?.id + const handoff: OptimizationHandoff = { + cache: { scope: 'private-request' }, + profileId, + } + if (!this.hasConsent('page')) { + this.reportBlockedEvent('page', [page]) + return { handoff } + } + let events: ExperienceEventPayload[] = [] + try { + if (!routeKey.startsWith('/')) + throw new TypeError('Request handoff requires a pathname/search route key.') + events = await this.buildHandoffEvents(initialEvents, page) + } catch (error) { + coreLogger.warn('Invalid request handoff inputs; no replay was prepared.', error) + return { handoff } + } + const preparedHandoff: OptimizationHandoff = { + ...handoff, + replay: { routeKey, events, locale: this.experienceOptions?.locale ?? this.core.locale }, + } + try { + const data = await this.core.api.experience.upsertProfile( + { profileId, events }, + { ...this.experienceOptions, preflight: true }, + ) + const { profile, selectedOptimizations } = data + this.currentProfile = profile + this.currentSelectedOptimizations = selectedOptimizations + return { handoff: { ...preparedHandoff, profileId: profileId ?? profile.id }, data } + } catch (error) { + coreLogger.warn('Request preview failed; the prepared handoff remains available.', error) + return { handoff: preparedHandoff } + } + } + + private async buildHandoffEvents( + initialEvents: readonly InitialExperienceEvent[] = [], + page: PageViewBuilderArgs = {}, + ): Promise { + const inputs: ExperienceEventPayload[] = [] + for (const input of initialEvents) { + if (!['identify', 'track'].includes(input.type)) + throw new TypeError('Initial handoff events must be identify or track inputs.') + if (!this.hasConsent(input.type)) { + this.reportBlockedEvent(input.type, [input]) + continue + } + inputs.push( + input.type === 'identify' + ? this.core.eventBuilder.buildIdentify(this.withEventContext(input)) + : this.core.eventBuilder.buildTrack(this.withEventContext(input)), + ) + } + inputs.push(this.core.eventBuilder.buildPageView(this.withEventContext(page))) + const events: Array = [] + for (const event of inputs) { + events.push( + await this.core.interceptors.event.run( + withEventConsent(event, this.requestEventConsent === true), + ), + ) + } + return validatePreparedEvents(events) + } + async identify( payload: StatelessExperiencePayload, ): Promise { @@ -455,7 +536,7 @@ export class CoreStatelessRequest { event: ExperienceEventPayload, ): Promise { const intercepted = await this.core.interceptors.event.run( - withRequestEventConsent(event, this.requestEventConsent === true), + withEventConsent(event, this.requestEventConsent === true), ) const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) const result = await this.core.api.experience.upsertProfile( @@ -478,7 +559,7 @@ export class CoreStatelessRequest { profile: PartialProfile, ): Promise { const intercepted = await this.core.interceptors.event.run( - withRequestEventConsent(event, this.requestEventConsent === true), + withEventConsent(event, this.requestEventConsent === true), ) const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) const batchEvent: BatchInsightsEventArray = parseWithFriendlyError(BatchInsightsEventArray, [ diff --git a/packages/universal/core-sdk/src/events/preparedEvents.ts b/packages/universal/core-sdk/src/events/preparedEvents.ts new file mode 100644 index 000000000..77ffb3e4c --- /dev/null +++ b/packages/universal/core-sdk/src/events/preparedEvents.ts @@ -0,0 +1,33 @@ +import { + ExperienceEventArray, + type ExperienceEvent, + type InsightsEvent, +} from '@contentful/optimization-api-client/api-schemas' + +const MAX_PROFILE_EVENTS = 200 +const MAX_IDENTIFY_EVENTS = 50 + +export function validatePreparedEvents(events: readonly unknown[]): ExperienceEventArray { + const validEvents = ExperienceEventArray.parse(events) + if ( + validEvents.length > MAX_PROFILE_EVENTS || + validEvents.filter(({ type }) => type === 'identify').length > MAX_IDENTIFY_EVENTS || + validEvents.at(-1)?.type !== 'page' || + validEvents.slice(0, -1).some(({ type }) => type !== 'identify' && type !== 'track') + ) { + throw new TypeError( + 'A handoff requires identify/track events followed by one page, within the single-profile request limits.', + ) + } + return validEvents +} + +export function withEventConsent( + event: TEvent, + isConsentGiven: boolean, +): TEvent { + return { + ...event, + context: { ...event.context, gdpr: { ...event.context.gdpr, isConsentGiven } }, + } +} diff --git a/packages/universal/core-sdk/src/handoff.test.ts b/packages/universal/core-sdk/src/handoff.test.ts index 8b198b342..0829c2aea 100644 --- a/packages/universal/core-sdk/src/handoff.test.ts +++ b/packages/universal/core-sdk/src/handoff.test.ts @@ -314,7 +314,8 @@ describe('handoff helpers', () => { ).toEqual([ { code: 'profile-state-in-public-cache', - message: 'Profile state should not be included in public or static optimization caches.', + message: + 'Profile state, identity and replay must not be included in public or static optimization caches.', path: ['state', 'profile'], }, ]) @@ -340,13 +341,34 @@ describe('handoff helpers', () => { }) describe('assertOptimizationCacheSafety', () => { + it.each(['static', 'public-permutation'] as const)( + 'excludes visitor identity and replay from %s output', + (scope) => { + const cache = scope === 'static' ? { scope } : { scope, key: 'public' } + expect(() => { + assertOptimizationCacheSafety({ cache, profileId: 'visitor' }) + }).toThrow(TypeError) + expect(() => { + assertOptimizationCacheSafety({ cache, replay: { routeKey: '/', events: [] } }) + }).toThrow(TypeError) + expect(() => { + assertOptimizationCacheSafety({ + cache: { scope: 'private-request' }, + profileId: 'visitor', + }) + }).not.toThrow() + }, + ) + it('throws for cache safety warnings', () => { expect(() => { assertOptimizationCacheSafety({ cache: { scope: 'static' }, state: { profile }, }) - }).toThrow('Profile state should not be included in public or static optimization caches.') + }).toThrow( + 'Profile state, identity and replay must not be included in public or static optimization caches.', + ) }) it('allows safe public permutations and static handoffs without profile state', () => { diff --git a/packages/universal/core-sdk/src/handoff.ts b/packages/universal/core-sdk/src/handoff.ts index 6416a743d..384f6525c 100644 --- a/packages/universal/core-sdk/src/handoff.ts +++ b/packages/universal/core-sdk/src/handoff.ts @@ -1,6 +1,7 @@ import type { ChainModifiers, EntrySkeletonType, LocaleCode } from 'contentful' import type { ChangeArray, + ExperienceEvent, Profile, SelectedOptimization, SelectedOptimizationArray, @@ -141,6 +142,12 @@ export interface OptimizationHandoff { readonly entries?: readonly ManagedEntryHandoff[] /** Cache metadata for the rendered output. */ readonly cache: OptimizationCacheMetadata + readonly profileId?: string + readonly replay?: { + readonly routeKey: string + readonly events: readonly ExperienceEvent[] + readonly locale?: string + } } /** @@ -358,16 +365,18 @@ export function getOptimizationCacheSafetyWarnings( handoff: OptimizationHandoff, ): readonly OptimizationCacheSafetyWarning[] { const warnings: OptimizationCacheSafetyWarning[] = [] - const { cache, state } = handoff + const { cache } = handoff + const privatePath = getPrivateHandoffPath(handoff) if ( (cache.scope === 'public-permutation' || cache.scope === 'static') && - state?.profile !== undefined + privatePath !== undefined ) { warnings.push({ code: 'profile-state-in-public-cache', - message: 'Profile state should not be included in public or static optimization caches.', - path: ['state', 'profile'], + message: + 'Profile state, identity and replay must not be included in public or static optimization caches.', + path: privatePath, }) } @@ -396,3 +405,10 @@ export function assertOptimizationCacheSafety(handoff: OptimizationHandoff): voi throw new TypeError(warnings.map((warning) => warning.message).join(' ')) } + +function getPrivateHandoffPath(handoff: OptimizationHandoff): string[] | undefined { + if (handoff.state?.profile !== undefined) return ['state', 'profile'] + if (handoff.profileId !== undefined) return ['profileId'] + if (handoff.replay !== undefined) return ['replay'] + return undefined +} diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts index d5091b407..c47c07708 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts @@ -20,7 +20,7 @@ const SAMPLE_DATA: OptimizationData = { } class ExperienceQueueTestHarness extends ExperienceQueue { - async invokeUpsert(events: ExperienceEventArray): Promise { + async invokeUpsert(events: ExperienceEventArray): Promise { return await this.upsertProfile(events) } } diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts index 8cb663cad..1db81f161 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts @@ -1,3 +1,4 @@ +import type { ExperienceApiClient } from '@contentful/optimization-api-client' import { ExperienceEvent as ExperienceEventSchema, parseWithFriendlyError, @@ -7,7 +8,11 @@ import { } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger } from '@contentful/optimization-api-client/logger' import type { LifecycleInterceptors } from '../CoreBase' -import type { EventOptimizationContext, OptimizationEventStreamEvent } from '../events' +import type { + EventEmissionResult, + EventOptimizationContext, + OptimizationEventStreamEvent, +} from '../events' import { QueueFlushRuntime, type ResolvedQueueFlushPolicy } from '../lib/queue' import { event as eventSignal, @@ -42,20 +47,21 @@ export interface ExperienceQueueDropContext { } interface ExperienceQueueOptions { - experienceApi: { - upsertProfile: (payload: { - profileId?: string - events: ExperienceEventArray - }) => Promise - } + experienceApi: Pick eventInterceptors: LifecycleInterceptors['event'] flushPolicy: ResolvedQueueFlushPolicy getAnonymousId: () => string | undefined + getResetToken?: () => object | undefined offlineMaxEvents: number onOfflineDrop?: (context: ExperienceQueueDropContext) => void stateInterceptors: LifecycleInterceptors['state'] } +interface QueuedExperienceBatch { + events: ExperienceEventArray + request?: { profileId?: string; locale?: string } +} + /** * Internal Experience send/offline runtime used by {@link CoreStateful}. * @@ -66,9 +72,10 @@ export class ExperienceQueue { private readonly eventInterceptors: ExperienceQueueOptions['eventInterceptors'] private readonly flushRuntime: QueueFlushRuntime private readonly getAnonymousId: ExperienceQueueOptions['getAnonymousId'] + private readonly getResetToken: () => object | undefined private readonly offlineMaxEvents: number private readonly onOfflineDrop?: ExperienceQueueOptions['onOfflineDrop'] - private readonly queuedExperienceEvents = new Set() + private readonly queuedExperienceEvents: QueuedExperienceBatch[] = [] private readonly stateInterceptors: ExperienceQueueOptions['stateInterceptors'] constructor(options: ExperienceQueueOptions) { @@ -84,6 +91,7 @@ export class ExperienceQueue { this.experienceApi = experienceApi this.eventInterceptors = eventInterceptors + this.getResetToken = options.getResetToken ?? (() => this) this.getAnonymousId = getAnonymousId this.offlineMaxEvents = offlineMaxEvents this.onOfflineDrop = onOfflineDrop @@ -104,7 +112,7 @@ export class ExperienceQueue { } clearQueuedEvents(): void { - this.queuedExperienceEvents.clear() + this.queuedExperienceEvents.length = 0 this.flushRuntime.reset() } @@ -112,7 +120,9 @@ export class ExperienceQueue { event: ExperienceEventPayload, optimizationContext?: EventOptimizationContext, ): Promise { + const token = this.getResetToken() const intercepted = await this.eventInterceptors.run(event) + if (token === undefined || token !== this.getResetToken()) return undefined const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) eventSignal.value = @@ -131,45 +141,89 @@ export class ExperienceQueue { return undefined } - async flush(options: { force?: boolean } = {}): Promise { - const { force = false } = options - - if (this.flushRuntime.shouldSkip({ force, isOnline: !!onlineSignal.value })) return + sendPrepared( + events: ExperienceEventArray, + target: { profileId?: string; locale?: string }, + ): EventEmissionResult { + if (this.getResetToken() === undefined) return { accepted: false } + if (this.getQueuedEventCount() + events.length > this.offlineMaxEvents) { + coreLogger.warn('Prepared Experience batch exceeds queue capacity; admission was rejected.') + return { accepted: false } + } + const submission: QueuedExperienceBatch = { + events, + request: { + ...target, + profileId: target.profileId ?? this.getAnonymousId() ?? profileSignal.value?.id, + }, + } + this.queuedExperienceEvents.push(submission) + events.forEach((event) => { + eventSignal.value = event + }) + void this.flush() + return { accepted: true } + } - if (this.queuedExperienceEvents.size === 0) { + async flush({ force = false }: { force?: boolean } = {}): Promise { + if ( + this.flushRuntime.shouldSkip({ + force, + isOnline: !!onlineSignal.value && this.getResetToken() !== undefined, + }) + ) + return + if (this.queuedExperienceEvents.length === 0) { this.flushRuntime.clearScheduledRetry() return } - coreLogger.debug('Flushing offline Experience event queue') - - const queuedEvents = Array.from(this.queuedExperienceEvents) + const token = this.getResetToken() + const isCurrent = (): boolean => token !== undefined && token === this.getResetToken() this.flushRuntime.markFlushStarted() - try { - const sendSuccess = await this.tryUpsertQueuedEvents(queuedEvents) - - if (sendSuccess) { - queuedEvents.forEach((queuedEvent) => { - this.queuedExperienceEvents.delete(queuedEvent) - }) - this.flushRuntime.handleFlushSuccess() - } else { + await this.sendQueuedBatches(token) + if (isCurrent()) this.flushRuntime.handleFlushSuccess() + } catch (error) { + coreLogger.warn('Experience queue flush request threw an error', error) + if (isCurrent()) this.flushRuntime.handleFlushFailure({ - queuedBatches: this.queuedExperienceEvents.size > 0 ? 1 : 0, - queuedEvents: this.queuedExperienceEvents.size, + queuedBatches: this.queuedExperienceEvents.length, + queuedEvents: this.getQueuedEventCount(), }) - } } finally { - this.flushRuntime.markFlushFinished() + if (isCurrent()) this.flushRuntime.markFlushFinished() + } + } + + private async sendQueuedBatches(token: object | undefined): Promise { + const { queuedExperienceEvents } = this + while (queuedExperienceEvents.length > 0) { + const [first] = queuedExperienceEvents + if (first === undefined) break + const { events: queuedEvents, request } = first + const events = [...queuedEvents] + await this.upsertProfile(events, request) + if (token !== this.getResetToken()) return + if (queuedExperienceEvents[0] === first) { + const offset = events.findIndex((event) => event === first.events[0]) + if (offset >= 0) first.events.splice(0, events.length - offset) + if (first.events.length === 0) queuedExperienceEvents.shift() + } } } + private getQueuedEventCount(): number { + let count = 0 + for (const { events } of this.queuedExperienceEvents) count += events.length + return count + } + private enqueueEvent(event: ExperienceEventPayload): void { let droppedEvents: ExperienceEventArray = [] - if (this.queuedExperienceEvents.size >= this.offlineMaxEvents) { - const dropCount = this.queuedExperienceEvents.size - this.offlineMaxEvents + 1 + if (this.getQueuedEventCount() >= this.offlineMaxEvents) { + const dropCount = this.getQueuedEventCount() - this.offlineMaxEvents + 1 droppedEvents = this.dropOldestEvents(dropCount) if (droppedEvents.length > 0) { @@ -179,27 +233,31 @@ export class ExperienceQueue { } } - this.queuedExperienceEvents.add(event) + const last = this.queuedExperienceEvents.at(-1) + if (last !== undefined && last.request === undefined) last.events.push(event) + else this.queuedExperienceEvents.push({ events: [event] }) if (droppedEvents.length > 0) { this.invokeOfflineDropCallback({ droppedCount: droppedEvents.length, droppedEvents, maxEvents: this.offlineMaxEvents, - queuedEvents: this.queuedExperienceEvents.size, + queuedEvents: this.getQueuedEventCount(), }) } } private dropOldestEvents(count: number): ExperienceEventArray { + const { queuedExperienceEvents } = this const droppedEvents: ExperienceEventArray = [] - for (let index = 0; index < count; index += 1) { - const oldestEvent = this.queuedExperienceEvents.values().next() - if (oldestEvent.done) break - - this.queuedExperienceEvents.delete(oldestEvent.value) - droppedEvents.push(oldestEvent.value) + while (droppedEvents.length < count) { + const [oldest] = queuedExperienceEvents + if (oldest === undefined) break + const { events, request } = oldest + const dropCount = request === undefined ? count - droppedEvents.length : events.length + droppedEvents.push(...events.splice(0, dropCount)) + if (events.length === 0) queuedExperienceEvents.shift() } return droppedEvents @@ -213,32 +271,34 @@ export class ExperienceQueue { } } - private async tryUpsertQueuedEvents(events: ExperienceEventArray): Promise { - try { - await this.upsertProfile(events) - return true - } catch (error) { - coreLogger.warn('Experience queue flush request threw an error', error) - return false - } - } - - protected async upsertProfile(events: ExperienceEventArray): Promise { - const anonymousId = this.getAnonymousId() - if (anonymousId) coreLogger.debug(`Anonymous ID found: ${anonymousId}`) - - experienceRequestStateSignal.value = { status: 'pending' } - + protected async upsertProfile( + events: ExperienceEventArray, + request?: QueuedExperienceBatch['request'], + ): Promise { + const token = this.getResetToken() + const isCurrent = (): boolean => token !== undefined && token === this.getResetToken() + if (isCurrent()) experienceRequestStateSignal.value = { status: 'pending' } try { - const data = await this.experienceApi.upsertProfile({ - profileId: anonymousId ?? profileSignal.value?.id, - events, - }) - - await applyOptimizationDataToSignals(data, this.stateInterceptors) - - return data + const send = async (): Promise => { + const profileId = request?.profileId ?? this.getAnonymousId() ?? profileSignal.value?.id + if (request !== undefined) request.profileId = profileId + const payload = { profileId, events } + const data = + request === undefined + ? await this.experienceApi.upsertProfile(payload) + : await this.experienceApi.upsertProfile(payload, { + preflight: false, + locale: request.locale ?? '', + }) + return data + } + const data = await send() + if (!isCurrent()) return undefined + if (request !== undefined) request.profileId ??= data.profile.id + await applyOptimizationDataToSignals(data, this.stateInterceptors, isCurrent) + return isCurrent() ? data : undefined } catch (error) { + if (!isCurrent()) return undefined experienceRequestStateSignal.value = { status: 'failed', reason: classifyExperienceRequestFailure(error), diff --git a/packages/universal/core-sdk/src/queues/InsightsQueue.ts b/packages/universal/core-sdk/src/queues/InsightsQueue.ts index 56dee4983..b58461c65 100644 --- a/packages/universal/core-sdk/src/queues/InsightsQueue.ts +++ b/packages/universal/core-sdk/src/queues/InsightsQueue.ts @@ -5,7 +5,7 @@ import { type BatchInsightsEventArray, type InsightsEventArray, type InsightsEvent as InsightsEventPayload, - type Profile, + type PartialProfile, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger } from '@contentful/optimization-api-client/logger' import type { LifecycleInterceptors } from '../CoreBase' @@ -18,11 +18,13 @@ const coreLogger = createScopedLogger('CoreStateful') const MAX_QUEUED_INSIGHTS_EVENTS = 25 interface QueuedProfileEvents { - profile: Profile + profile: PartialProfile events: InsightsEventArray } interface InsightsQueueOptions { + getAnonymousId?: () => string | undefined + getResetToken?: () => object | undefined eventInterceptors: LifecycleInterceptors['event'] flushPolicy: ResolvedQueueFlushPolicy insightsApi: { @@ -47,7 +49,9 @@ export class InsightsQueue { private readonly flushIntervalMs: number private readonly flushRuntime: QueueFlushRuntime private readonly insightsApi: InsightsQueueOptions['insightsApi'] - private readonly queuedInsightsByProfile = new Map() + private readonly queuedInsightsByProfile = new Map() + private readonly getAnonymousId: () => string | undefined + private readonly getResetToken: () => object | undefined private insightsPeriodicFlushTimer: ReturnType | undefined constructor(options: InsightsQueueOptions) { @@ -55,6 +59,8 @@ export class InsightsQueue { const { flushIntervalMs } = flushPolicy this.eventInterceptors = eventInterceptors + this.getAnonymousId = options.getAnonymousId ?? (() => undefined) + this.getResetToken = options.getResetToken ?? (() => this) this.flushIntervalMs = flushIntervalMs this.insightsApi = insightsApi this.flushRuntime = new QueueFlushRuntime({ @@ -89,7 +95,10 @@ export class InsightsQueue { event: InsightsEventPayload, optimizationContext?: EventOptimizationContext, ): Promise { - const { value: profile } = profileSignal + const token = this.getResetToken() + const anonymousId = this.getAnonymousId() + const profile = + profileSignal.value ?? (anonymousId === undefined ? undefined : { id: anonymousId }) if (!profile) { coreLogger.warn('Attempting to emit an event without an Optimization profile') @@ -97,6 +106,7 @@ export class InsightsQueue { } const intercepted = await this.eventInterceptors.run(event) + if (token === undefined || token !== this.getResetToken()) return const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) coreLogger.debug(`Queueing ${validEvent.type} event for profile ${profile.id}`, validEvent) @@ -128,7 +138,13 @@ export class InsightsQueue { async flush(options: InsightsQueueFlushOptions = {}): Promise { const { force = false, beacon } = options - if (this.flushRuntime.shouldSkip({ force, isOnline: !!onlineSignal.value })) return + if ( + this.flushRuntime.shouldSkip({ + force, + isOnline: !!onlineSignal.value && this.getResetToken() !== undefined, + }) + ) + return coreLogger.debug('Flushing insights event queue') @@ -141,12 +157,15 @@ export class InsightsQueue { } this.flushRuntime.markFlushStarted() + const token = this.getResetToken() + const isCurrent = (): boolean => token !== undefined && token === this.getResetToken() try { - const sendSuccess = await this.trySendBatches(batches, beacon ? { beacon } : undefined) + const sendSuccess = await this.trySendBatches(batches, beacon) + if (!isCurrent()) return if (sendSuccess) { - this.queuedInsightsByProfile.clear() + this.removeSentEvents(batches) this.flushRuntime.handleFlushSuccess() } else { this.flushRuntime.handleFlushFailure({ @@ -155,8 +174,19 @@ export class InsightsQueue { }) } } finally { - this.flushRuntime.markFlushFinished() - this.reconcilePeriodicFlushTimer() + if (isCurrent()) { + this.flushRuntime.markFlushFinished() + this.reconcilePeriodicFlushTimer() + } + } + } + + private removeSentEvents(batches: BatchInsightsEventArray): void { + for (const { profile, events } of batches) { + const queued = this.queuedInsightsByProfile.get(profile.id) + if (queued === undefined) continue + queued.events.splice(0, events.length) + if (queued.events.length === 0) this.queuedInsightsByProfile.delete(profile.id) } } @@ -164,7 +194,7 @@ export class InsightsQueue { const batches: BatchInsightsEventArray = [] this.queuedInsightsByProfile.forEach(({ profile, events }) => { - batches.push({ profile, events }) + batches.push({ profile, events: [...events] }) }) return batches @@ -172,12 +202,12 @@ export class InsightsQueue { private async trySendBatches( batches: BatchInsightsEventArray, - options: InsightsApiClientRequestOptions | undefined, + beacon: InsightsApiClientRequestOptions['beacon'], ): Promise { try { - if (options === undefined) return await this.insightsApi.sendBatchEvents(batches) + if (beacon === undefined) return await this.insightsApi.sendBatchEvents(batches) - return await this.insightsApi.sendBatchEvents(batches, options) + return await this.insightsApi.sendBatchEvents(batches, { beacon }) } catch (error) { coreLogger.warn('Insights queue flush request threw an error', error) return false diff --git a/packages/universal/core-sdk/src/state/applyOptimizationDataToSignals.ts b/packages/universal/core-sdk/src/state/applyOptimizationDataToSignals.ts index e7a7d6758..4bb3a365f 100644 --- a/packages/universal/core-sdk/src/state/applyOptimizationDataToSignals.ts +++ b/packages/universal/core-sdk/src/state/applyOptimizationDataToSignals.ts @@ -13,12 +13,14 @@ import { export async function applyOptimizationDataToSignals( data: OptimizationData, stateInterceptors: LifecycleInterceptors['state'], + isCurrent: () => boolean = () => true, ): Promise { const intercepted = mergeOptimizationSelectionState( data, await stateInterceptors.run(data, mergeOptimizationSelectionState), ) const { changes, profile, selectedOptimizations } = intercepted + if (!isCurrent()) return // success must be written inside this batch because experienceRequestState transitions // to 'success' atomically with selectedOptimizations so consumers never observe From 94af72c148c491779792e1984a8e26d4377b8d69 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Wed, 7 Oct 2026 11:19:04 +0200 Subject: [PATCH 03/29] feat(web)!: commit paired handoffs through shared queues --- .../src/ContentfulOptimization.test.ts | 8 +- packages/node/node-sdk/src/handoff.test.ts | 29 +- packages/web/web-sdk/package.json | 26 +- .../src/ContentfulOptimization.test.ts | 55 ---- .../web/web-sdk/src/ContentfulOptimization.ts | 232 ++++++++------ packages/web/web-sdk/src/analytics.test.ts | 49 +-- packages/web/web-sdk/src/analytics.ts | 81 +---- packages/web/web-sdk/src/handoff-internal.ts | 194 ++++++++++++ packages/web/web-sdk/src/handoff.test.ts | 296 ++++++++++++++++-- packages/web/web-sdk/src/handoff.ts | 237 +------------- packages/web/web-sdk/src/lib/cookies.test.ts | 24 ++ packages/web/web-sdk/src/lib/cookies.ts | 28 +- packages/web/web-sdk/src/runtime.ts | 3 +- .../web-sdk/src/web-components/index.test.ts | 1 + 14 files changed, 727 insertions(+), 536 deletions(-) create mode 100644 packages/web/web-sdk/src/handoff-internal.ts diff --git a/packages/node/node-sdk/src/ContentfulOptimization.test.ts b/packages/node/node-sdk/src/ContentfulOptimization.test.ts index 7629fceda..1e388d7fd 100644 --- a/packages/node/node-sdk/src/ContentfulOptimization.test.ts +++ b/packages/node/node-sdk/src/ContentfulOptimization.test.ts @@ -164,7 +164,7 @@ describe('ContentfulOptimization', () => { .mockResolvedValue(OPTIMIZATION_DATA) const requestOptimization = node.forRequest({ consent: true, - experienceOptions: { locale: 'de-DE', preflight: true }, + experienceOptions: { locale: 'de-DE' }, profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) @@ -182,7 +182,7 @@ describe('ContentfulOptimization', () => { }), ], }), - expect.objectContaining({ locale: 'de-DE', preflight: true }), + expect.objectContaining({ locale: 'de-DE' }), ) }) @@ -194,7 +194,7 @@ describe('ContentfulOptimization', () => { const requestOptimization = node.forRequest({ consent: true, eventContext: { locale: 'en-US' }, - experienceOptions: { locale: 'fr-FR', preflight: true }, + experienceOptions: { locale: 'fr-FR' }, locale: ' de_DE ', profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) @@ -214,7 +214,7 @@ describe('ContentfulOptimization', () => { }), ], }), - expect.objectContaining({ locale: 'de-DE', preflight: true }), + expect.objectContaining({ locale: 'de-DE' }), ) }) diff --git a/packages/node/node-sdk/src/handoff.test.ts b/packages/node/node-sdk/src/handoff.test.ts index cdfe056c0..c839f0ae6 100644 --- a/packages/node/node-sdk/src/handoff.test.ts +++ b/packages/node/node-sdk/src/handoff.test.ts @@ -5,6 +5,8 @@ import type { PrivateRequestOptimizationCacheMetadata, } from '@contentful/optimization-core' import type { Entry, EntrySkeletonType } from 'contentful' +import { OPTIMIZATION_NODE_SDK_NAME } from './constants' +import ContentfulOptimization from './ContentfulOptimization' import { createRequestHandoffFromData } from './handoff' type TestEntry = Entry @@ -83,7 +85,13 @@ const requestData: OptimizationData = { } describe('createRequestHandoffFromData', () => { - it('maps completed request OptimizationData into Core handoff state', () => { + it('composes prepared Node events with request rendering state and entries', async () => { + const node = new ContentfulOptimization({ spaceId: 'key_123' }) + const preview = rs.spyOn(node.api.experience, 'upsertProfile').mockResolvedValue(requestData) + const prepared = await node.forRequest({ consent: true }).prepareRequestHandoff({ + routeKey: '/products?campaign=one', + initialEvents: [{ type: 'identify', userId: 'customer' }], + }) const cache: PrivateRequestOptimizationCacheMetadata = { scope: 'private-request', } @@ -93,12 +101,21 @@ describe('createRequestHandoffFromData', () => { entryId: '4ib0hsHWoSOnCVdDkizE8d', }, ] - const handoff = createRequestHandoffFromData({ - cache, - data: requestData, - entries, - }) + const handoff = { + ...prepared.handoff, + ...createRequestHandoffFromData({ cache, data: prepared.data, entries }), + } + expect(preview).toHaveBeenCalledTimes(1) + expect(handoff.profileId).toBe(requestData.profile.id) + expect(handoff.replay).toBe(prepared.handoff.replay) + expect(handoff.replay?.events.map(({ type }) => type)).toEqual(['identify', 'page']) + expect( + handoff.replay?.events.every( + ({ channel, context }) => + channel === 'server' && context.library.name === OPTIMIZATION_NODE_SDK_NAME, + ), + ).toBe(true) expect(handoff.cache).toBe(cache) expect(handoff.entries).toBe(entries) expect(handoff.state).toEqual({ diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index fa50bb739..825aa9750 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -151,24 +151,24 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "contentful-optimization-web.umd.js": 36600, - "index.cjs": 11800, - "index.mjs": 12400, + "contentful-optimization-web.umd.js": 38600, + "index.cjs": 12400, + "index.mjs": 12700, "bridge-support.cjs": 700, "bridge-support.mjs": 300, - "runtime.cjs": 1000, + "runtime.cjs": 1100, "runtime.mjs": 500, - "handoff.cjs": 1700, - "handoff.mjs": 1600, - "analytics.cjs": 12900, - "analytics.mjs": 14100, - "contentful-optimization-web-components.umd.js": 42100, + "handoff.cjs": 1600, + "handoff.mjs": 1200, + "analytics.cjs": 12700, + "analytics.mjs": 13000, + "contentful-optimization-web-components.umd.js": 44000, "presentation.cjs": 3600, - "presentation.mjs": 3500, + "presentation.mjs": 3300, "tracking-attributes.cjs": 1000, - "tracking-attributes.mjs": 700, - "web-components.cjs": 17200, - "web-components.mjs": 18700 + "tracking-attributes.mjs": 600, + "web-components.cjs": 17700, + "web-components.mjs": 18800 } } }, diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 96fac3712..bdc462ca1 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -566,61 +566,6 @@ describe('ContentfulOptimization', () => { expect(upsertProfile).toHaveBeenCalledTimes(1) }) - it('can mark an SSR-emitted initial current page as accepted', async () => { - const web = new ContentfulOptimization(config) - const upsertProfile = rs - .spyOn(web.api.experience, 'upsertProfile') - .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - - await expect( - web.trackCurrentPage({ - initialPageEvent: 'skip', - routeKey: '/', - }), - ).resolves.toEqual({ accepted: true }) - await expect( - web.trackCurrentPage({ - routeKey: '/', - buildPayload: () => ({}), - }), - ).resolves.toEqual({ accepted: false }) - - expect(upsertProfile).not.toHaveBeenCalled() - }) - - it('can mark an SSR-emitted current page as accepted after another route', async () => { - const web = new ContentfulOptimization(config) - const upsertProfile = rs - .spyOn(web.api.experience, 'upsertProfile') - .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - const skippedPayload = rs.fn(() => ({})) - const dedupedPayload = rs.fn(() => ({})) - - await expect( - web.trackCurrentPage({ - routeKey: '/', - buildPayload: () => ({}), - }), - ).resolves.toEqual({ accepted: true, data: EMPTY_OPTIMIZATION_DATA }) - await expect( - web.trackCurrentPage({ - initialPageEvent: 'skip', - routeKey: '/page-two', - buildPayload: skippedPayload, - }), - ).resolves.toEqual({ accepted: true }) - await expect( - web.trackCurrentPage({ - routeKey: '/page-two', - buildPayload: dedupedPayload, - }), - ).resolves.toEqual({ accepted: false }) - - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(skippedPayload).not.toHaveBeenCalled() - expect(dedupedPayload).not.toHaveBeenCalled() - }) - it('forwards onEventBlocked callback to core stateful guards', async () => { const onEventBlocked = rs.fn() const web = new ContentfulOptimization({ ...config, onEventBlocked }) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 8c2fd40de..61b8f38ee 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -12,12 +12,15 @@ import { AcceptedCurrentStateTracker, + assertOptimizationCacheSafety, CoreStateful, effect, resolveStatefulDefaults, signals, + type ConsentInput, type CoreStatefulConfig, type EventEmissionResult, + type OptimizationHandoff, type PageViewBuilderArgs, } from '@contentful/optimization-core' import type { App } from '@contentful/optimization-core/api-schemas' @@ -41,6 +44,11 @@ import { createOnlineChangeListener, createVisibilityChangeListener, } from './handlers' +import { + clearHandoffInitialization, + getHandoffInitialization, + hydrateOptimizationHandoff, +} from './handoff-internal' import { getCookie, removeCookie, setCookie, type CookieAttributes } from './lib/cookies' import { clearProfilelessHandoffDurableContinuity, @@ -116,13 +124,6 @@ export interface CurrentPageEmissionMetadata { readonly isInitialEmission: boolean } -/** - * Controls how {@link ContentfulOptimization.trackCurrentPage} treats the current route. - * - * @public - */ -export type InitialCurrentPageEvent = 'emit' | 'skip' - /** * Options for {@link ContentfulOptimization.trackCurrentPage}. * @@ -133,11 +134,6 @@ export interface TrackCurrentPageOptions { * Stable route identity used for current-page deduplication. */ readonly routeKey: string - /** - * Controls the current route emission. SSR integrations can use `skip` when - * the server already emitted this route's page event. - */ - readonly initialPageEvent?: InitialCurrentPageEvent /** * Builds the page payload only when a page event will be emitted. */ @@ -145,22 +141,13 @@ export interface TrackCurrentPageOptions { } /** - * Skip-only options for {@link ContentfulOptimization.trackCurrentPage}. + * Route identity and ordinary-page builder used during browser takeover. * * @public */ -export interface TrackCurrentPageSkipOptions { - /** - * Stable route identity used for current-page deduplication. - */ +export interface HydrateAndTrackCurrentPageOptions { readonly routeKey: string - /** - * Marks the current route accepted without emitting a page event. - */ - readonly initialPageEvent: 'skip' - /** - * Ignored for skip-only tracking. Kept for callers that share option builders. - */ + /** Used only when the handoff has no replay for this route. */ readonly buildPayload?: TrackCurrentPageOptions['buildPayload'] } @@ -274,6 +261,8 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { declare readonly [CORE_BRIDGE_CAPABILITIES_SYMBOL]: CoreBridgeCapabilities private readonly currentPageTracker = new AcceptedCurrentStateTracker() + private readonly handoffIdentity: { profileId?: string } + private readonly effectDisposers: Array<() => void> = [] /** * Tracked entry interaction runtime state and trackers. @@ -335,8 +324,14 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { const { autoTrackEntryInteraction, ...restConfig } = config const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) + const handoffIdentity: { profileId?: string } = {} + const { getAnonymousId } = mergedConfig + mergedConfig.getAnonymousId = () => + signals.profile.value?.id ?? handoffIdentity.profileId ?? getAnonymousId?.() super(mergedConfig) + this.handoffIdentity = handoffIdentity + clearHandoffInitialization() clearProfilelessHandoffDurableContinuity() const canLoadPersistedContinuity = mergedConfig.defaults?.persistenceConsent === true @@ -348,7 +343,7 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { this.tracking = tracking this.cookieAttributes = { - domain: mergedConfig.cookie?.domain, + ...mergedConfig.cookie, expires: mergedConfig.cookie?.expires ?? EXPIRATION_DAYS_DEFAULT, } @@ -361,62 +356,61 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { await this.flushQueues({ force: true, beacon: beaconHandler }) }) - effect(() => { - const { - changes: { value }, - persistenceConsent: { value: persistenceConsent }, - } = signals - - if (canPersistDurableContinuity(persistenceConsent)) LocalStore.changes = value - }) - - effect(() => { - const { - consent: { value }, - } = signals - - this.entryInteractionRuntime.syncAutoTrackedEntryInteractions() - LocalStore.consent = value - }) - - effect(() => { - const { - persistenceConsent: { value }, - } = signals - - LocalStore.persistenceConsent = value - if (value === true) this.initializeFromCurrentCookieValues() - if (value === false) { - removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) - removeCookie(ANONYMOUS_ID_COOKIE_LEGACY, this.cookieAttributes) - LocalStore.clearProfileContinuity() - } - }) - - effect(() => { - const { - persistenceConsent: { value: persistenceConsent }, - profile: { value }, - } = signals - - if (value !== undefined && !isDurableContinuityPersistenceSuppressed()) { - clearProfilelessHandoffDurableContinuity() - } - - if (persistenceConsent !== true) return - - LocalStore.profile = value - this.setAnonymousId(value?.id ?? LocalStore.anonymousId) - }) - - effect(() => { - const { - persistenceConsent: { value: persistenceConsent }, - selectedOptimizations: { value }, - } = signals - - if (canPersistDurableContinuity(persistenceConsent)) LocalStore.selectedOptimizations = value - }) + this.effectDisposers.push( + effect(() => { + const { + changes: { value }, + persistenceConsent: { value: persistenceConsent }, + } = signals + + if (canPersistDurableContinuity(persistenceConsent)) LocalStore.changes = value + }), + effect(() => { + const { + consent: { value }, + } = signals + + this.entryInteractionRuntime.syncAutoTrackedEntryInteractions() + LocalStore.consent = value + }), + effect(() => { + const { + persistenceConsent: { value }, + } = signals + + LocalStore.persistenceConsent = value + if (value === true) this.initializeFromCurrentCookieValues() + if (value === false) { + removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) + removeCookie(ANONYMOUS_ID_COOKIE_LEGACY, this.cookieAttributes) + LocalStore.clearProfileContinuity() + } + }), + effect(() => { + const { + persistenceConsent: { value: persistenceConsent }, + profile: { value }, + } = signals + + if (value !== undefined && !isDurableContinuityPersistenceSuppressed()) { + clearProfilelessHandoffDurableContinuity() + } + + if (persistenceConsent !== true) return + + LocalStore.profile = value + this.setAnonymousId(value?.id ?? this.handoffIdentity.profileId ?? LocalStore.anonymousId) + }), + effect(() => { + const { + persistenceConsent: { value: persistenceConsent }, + selectedOptimizations: { value }, + } = signals + + if (canPersistDurableContinuity(persistenceConsent)) + LocalStore.selectedOptimizations = value + }), + ) this.initializeFromCookieValues(cookieValue, legacyCookieValue) @@ -489,6 +483,8 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * @public */ reset(): void { + clearHandoffInitialization() + this.handoffIdentity.profileId = undefined this.currentPageTracker.reset() this.entryInteractionRuntime.reset() removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) @@ -506,15 +502,9 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * * @public */ - async trackCurrentPage( - options: TrackCurrentPageOptions | TrackCurrentPageSkipOptions, - ): Promise { + async trackCurrentPage(options: TrackCurrentPageOptions): Promise { const { routeKey } = options - - if (options.initialPageEvent === 'skip') { - this.currentPageTracker.markAccepted(routeKey) - return { accepted: true } - } + clearHandoffInitialization(routeKey) const { buildPayload } = options const isInitialEmission = !this.currentPageTracker.hasAccepted() @@ -530,6 +520,66 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { return { accepted: true, data: result.data } } + /** + * Hydrate the current render and admit its prepared events through the Experience queue. + * Paired work settles on admission; HTTP completion updates live state independently. + * Repeated current initialization shares work. Navigation and fresh preparations remain trackable. + * @public + */ + async hydrateAndTrackCurrentPage( + handoff: OptimizationHandoff, + { routeKey, buildPayload }: HydrateAndTrackCurrentPageOptions, + ): Promise { + assertOptimizationCacheSafety(handoff) + const initialization = getHandoffInitialization(handoff, routeKey) + if (initialization.delivery !== undefined) return await initialization.delivery + + initialization.delivery = Promise.resolve().then(async () => { + if (!initialization.isCurrent()) return { accepted: false } + const profileId = this.adoptHandoffIdentity(handoff) + await hydrateOptimizationHandoff(this, handoff, { routeKey }) + if (!initialization.isCurrent()) return { accepted: false } + + const { replay } = handoff + if (replay?.routeKey !== routeKey || replay.events.at(-1)?.type !== 'page') { + return await this.trackCurrentPage({ routeKey, buildPayload: buildPayload ?? (() => ({})) }) + } + const result = await this.sendPreparedExperienceEvents( + replay.events, + profileId, + replay.locale, + ) + if (result.accepted && initialization.isCurrent()) + this.currentPageTracker.markAccepted(routeKey) + return result + }) + try { + const result = await initialization.delivery + if (!result.accepted) initialization.delivery = undefined + return result + } catch (error) { + initialization.delivery = undefined + throw error + } + } + + private adoptHandoffIdentity(handoff: OptimizationHandoff): string | undefined { + const profileId = + signals.profile.value?.id ?? this.config.getAnonymousId?.() ?? handoff.profileId + this.handoffIdentity.profileId = profileId + if (this.states.persistenceConsent.current === true && profileId !== undefined) { + this.setAnonymousId(profileId) + } + return profileId + } + + override consent(accept: ConsentInput): void { + if ((typeof accept === 'boolean' ? accept : accept.events) === false) { + if (clearHandoffInitialization()) this.currentPageTracker.reset() + } + super.consent(accept) + } + /** * Destroy the Web SDK instance and release runtime resources. * @@ -538,6 +588,10 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * clear persisted user state. */ destroy(): void { + clearHandoffInitialization() + this.effectDisposers.splice(0).forEach((dispose) => { + dispose() + }) this.entryInteractionRuntime.destroy() this.cleanupOnlineListener() this.cleanupVisibilityListener() diff --git a/packages/web/web-sdk/src/analytics.test.ts b/packages/web/web-sdk/src/analytics.test.ts index 28e796e21..153685225 100644 --- a/packages/web/web-sdk/src/analytics.test.ts +++ b/packages/web/web-sdk/src/analytics.test.ts @@ -98,7 +98,6 @@ function createAnalyticsHandoff( return { cache: { scope: 'private-request' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { profile, selectedOptimizations, @@ -305,7 +304,6 @@ describe('Optimization analytics handoff runtime', () => { expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: secondPayload, - initialPageEvent: 'emit', routeKey: '/segment-b', }) @@ -383,32 +381,6 @@ describe('Optimization analytics handoff runtime', () => { expect(LocalStore.selectedOptimizations).toEqual(selectedOptimizations) }) - it('warns without throwing when skipping the page event without profile continuity', async () => { - const warn = rs.spyOn(console, 'warn').mockImplementation(() => undefined) - runtime = initializeOptimizationAnalyticsRuntime({ - ...config, - logLevel: 'warn', - }) - - await expect( - hydrateOptimizationAnalyticsHandoff( - runtime, - createAnalyticsHandoff({ - initialPageEvent: 'skip', - state: { selectedOptimizations }, - }), - { - routeKey: '/segment-a', - buildPagePayload: () => ({}), - }, - ), - ).resolves.toBeUndefined() - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('without handoff profile state or browser profile continuity'), - ) - }) - it('rejects content handoffs', async () => { runtime = initializeOptimizationAnalyticsRuntime(config) @@ -418,7 +390,6 @@ describe('Optimization analytics handoff runtime', () => { { cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', }, { routeKey: '/', @@ -428,24 +399,6 @@ describe('Optimization analytics handoff runtime', () => { ).rejects.toThrow('analytics-only optimization handoffs') }) - it('rejects invalid initialPageEvent values', async () => { - runtime = initializeOptimizationAnalyticsRuntime(config) - - await expect( - Reflect.apply(hydrateOptimizationAnalyticsHandoff, undefined, [ - runtime, - { - ...createAnalyticsHandoff(), - initialPageEvent: 'invalid', - }, - { - routeKey: '/', - buildPagePayload: () => ({}), - }, - ]), - ).rejects.toThrow('initialPageEvent') - }) - it('rejects public profile state before hydrating browser signals', async () => { runtime = initializeOptimizationAnalyticsRuntime(config) @@ -461,7 +414,7 @@ describe('Optimization analytics handoff runtime', () => { }, ), ).rejects.toThrow( - 'Profile state should not be included in public or static optimization caches.', + 'Profile state, identity and replay must not be included in public or static optimization caches.', ) expect(signals.profile.value).toBeUndefined() diff --git a/packages/web/web-sdk/src/analytics.ts b/packages/web/web-sdk/src/analytics.ts index 58687a2cf..619d8863a 100644 --- a/packages/web/web-sdk/src/analytics.ts +++ b/packages/web/web-sdk/src/analytics.ts @@ -4,23 +4,15 @@ * @packageDocumentation */ -import { assertOptimizationCacheSafety } from '@contentful/optimization-core' -import { createScopedLogger } from '@contentful/optimization-core/logger' import ContentfulOptimization, { type OptimizationTrackingApi, type OptimizationWebConfig, type TrackCurrentPageOptions, } from './ContentfulOptimization' -import { - hydrateOptimizationHandoffState, - shouldPreserveDurableContinuity, - type AnalyticsOptimizationHandoff, -} from './handoff' - -const logger = createScopedLogger('Web:AnalyticsHandoff') +import { hydrateOptimizationHandoff, type AnalyticsOptimizationHandoff } from './handoff' +import { getHandoffInitialization } from './handoff-internal' const runtimeSdks = new WeakMap() -let latestAnalyticsHandoffHydration = 0 /** * Options used when hydrating an analytics-only handoff. @@ -30,7 +22,7 @@ let latestAnalyticsHandoffHydration = 0 export interface HydrateOptimizationAnalyticsHandoffOptions { /** Stable route identity used for current-page deduplication. */ readonly routeKey: string - /** Builds the browser page payload when `handoff.initialPageEvent` is `emit`. */ + /** Builds an ordinary browser page payload when there is no matching replay. */ readonly buildPagePayload: TrackCurrentPageOptions['buildPayload'] /** Cancels async hydration before state apply or page tracking. */ readonly isCurrent?: () => boolean @@ -48,36 +40,12 @@ export interface OptimizationAnalyticsRuntime { flush: () => Promise /** Track the current route with the same semantics as the full Web SDK. */ trackCurrentPage: ContentfulOptimization['trackCurrentPage'] + /** Hydrate and admit the current handoff through the shared Web operation. */ + hydrateAndTrackCurrentPage: ContentfulOptimization['hydrateAndTrackCurrentPage'] /** Release browser listeners and queued runtime resources. */ destroy: () => void } -function getRuntimeSdk(runtime: OptimizationAnalyticsRuntime): ContentfulOptimization { - const sdk = runtimeSdks.get(runtime) - - if (sdk === undefined) { - throw new TypeError('Unknown Optimization analytics runtime.') - } - - return sdk -} - -function hasProfileContinuity(sdk: ContentfulOptimization): boolean { - return sdk.states.persistenceConsent.current === true && sdk.states.profile.current !== undefined -} - -function warnSkippedInitialPageWithoutProfileContinuity( - sdk: ContentfulOptimization, - handoff: AnalyticsOptimizationHandoff, -): void { - if (handoff.initialPageEvent !== 'skip') return - if (handoff.state?.profile !== undefined || hasProfileContinuity(sdk)) return - - logger.warn( - 'Analytics-only handoff skipped the initial page event without handoff profile state or browser profile continuity.', - ) -} - function assertAnalyticsHandoff(handoff: AnalyticsOptimizationHandoff): void { const hydration: unknown = handoff.hydration @@ -88,12 +56,6 @@ function assertAnalyticsHandoff(handoff: AnalyticsOptimizationHandoff): void { ) } -function assertInitialPageEvent(initialPageEvent: unknown): void { - if (initialPageEvent === 'emit' || initialPageEvent === 'skip') return - - throw new TypeError('Optimization handoff requires initialPageEvent to be "emit" or "skip".') -} - /** * Initialize the analytics-only browser runtime. * @@ -119,16 +81,17 @@ export function initializeOptimizationAnalyticsRuntime( await sdk.flush() }, trackCurrentPage: async (options) => await sdk.trackCurrentPage(options), + hydrateAndTrackCurrentPage: async (handoff, options) => + await sdk.hydrateAndTrackCurrentPage(handoff, options), tracking: sdk.tracking, } - runtimeSdks.set(runtime, sdk) return runtime } /** - * Hydrate analytics-only browser state and emit or mark the current page event. + * Hydrate analytics-only browser state and admit its replay or ordinary current page. * * @param runtime - Analytics runtime returned by {@link initializeOptimizationAnalyticsRuntime}. * @param handoff - Analytics-only browser handoff. @@ -142,27 +105,17 @@ export async function hydrateOptimizationAnalyticsHandoff( options: HydrateOptimizationAnalyticsHandoffOptions, ): Promise { assertAnalyticsHandoff(handoff) - assertInitialPageEvent(handoff.initialPageEvent) - assertOptimizationCacheSafety(handoff) - const sdk = getRuntimeSdk(runtime) - latestAnalyticsHandoffHydration += 1 - const hydration = latestAnalyticsHandoffHydration - const isCurrent = (): boolean => - hydration === latestAnalyticsHandoffHydration && options.isCurrent?.() !== false - - await hydrateOptimizationHandoffState(sdk, handoff.state, { - isCurrent, - suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), + if (options.isCurrent?.() === false) return + const sdk = runtimeSdks.get(runtime) + if (sdk === undefined) throw new TypeError('Unknown Optimization analytics runtime.') + const initialization = getHandoffInitialization(handoff, options.routeKey) + await hydrateOptimizationHandoff(sdk, handoff, { + routeKey: options.routeKey, + isCurrent: options.isCurrent, }) - if (!isCurrent()) return - - warnSkippedInitialPageWithoutProfileContinuity(sdk, handoff) - - if (!isCurrent()) return - - await runtime.trackCurrentPage({ + if (!initialization.isCurrent() || options.isCurrent?.() === false) return + await runtime.hydrateAndTrackCurrentPage(handoff, { buildPayload: options.buildPagePayload, - initialPageEvent: handoff.initialPageEvent, routeKey: options.routeKey, }) } diff --git a/packages/web/web-sdk/src/handoff-internal.ts b/packages/web/web-sdk/src/handoff-internal.ts new file mode 100644 index 000000000..d419e02db --- /dev/null +++ b/packages/web/web-sdk/src/handoff-internal.ts @@ -0,0 +1,194 @@ +import { + assertOptimizationCacheSafety, + batch, + hasOptimizationSelectionStateField, + mergeOptimizationSelectionState, + signals, + type EventEmissionResult, + type OptimizationHandoff, + type OptimizationSelectionState, +} from '@contentful/optimization-core' +import type { HandoffStateHydrationOptions, OptimizationHandoffHydrationTarget } from './handoff' +import { + preserveProfilelessHandoffDurableContinuity, + suppressDurableContinuityPersistence, +} from './storage/durableContinuityPersistence' + +const CONTENT_STATE_RESET: OptimizationSelectionState = { + changes: undefined, + selectedOptimizations: undefined, +} + +let latestHandoffStateHydration = 0 + +/** The current initialization only; admitted batches remain owned by Core's queue. @internal */ +export interface HandoffInitialization { + readonly key: string | OptimizationHandoff + readonly routeKey: string + readonly isCurrent: () => boolean + hydration?: Promise + delivery?: Promise +} + +let currentHandoffInitialization: HandoffInitialization | undefined = undefined + +/** Join current work before seeding state, including state-only framework initialization. @internal */ +export function getHandoffInitialization( + handoff: OptimizationHandoff, + routeKey: string, +): HandoffInitialization { + const key = handoff.replay?.events.at(-1)?.messageId ?? handoff + const current = currentHandoffInitialization + if (current?.routeKey === routeKey && current.key === key) return current + + const initialization: HandoffInitialization = { + key, + routeKey, + isCurrent: () => currentHandoffInitialization === initialization, + } + currentHandoffInitialization = initialization + return initialization +} + +/** Invalidate unfinished hydration when the SDK resets, is cleared or is destroyed. @internal */ +export function clearHandoffInitialization(routeKey?: string): boolean { + if (routeKey !== undefined && currentHandoffInitialization?.routeKey === routeKey) return false + const hadInitialization = currentHandoffInitialization !== undefined + currentHandoffInitialization = undefined + latestHandoffStateHydration += 1 + return hadInitialization +} + +function shouldContinueHydration(options: HandoffStateHydrationOptions): boolean { + return options.isCurrent?.() !== false +} + +/** + * @internal + */ +export function shouldPreserveDurableContinuity(handoff: OptimizationHandoff): boolean { + return ( + (handoff.cache.scope === 'public-permutation' || handoff.cache.scope === 'static') && + handoff.state?.profile === undefined + ) +} + +function applyHydratedSignals( + state: OptimizationSelectionState, + options: HandoffStateHydrationOptions, +): void { + const { changes, profile, selectedOptimizations } = state + const { + changes: changesSignal, + experienceRequestState, + profile: profileSignal, + selectedOptimizations: selectedOptimizationsSignal, + } = signals + + const updateSignals = (): void => { + batch(() => { + changesSignal.value = changes + if (hasOptimizationSelectionStateField(state, 'profile')) profileSignal.value = profile + selectedOptimizationsSignal.value = selectedOptimizations + + experienceRequestState.value = { status: 'success' } + }) + } + + if (options.suppressDurableContinuityPersistence === true) { + preserveProfilelessHandoffDurableContinuity() + suppressDurableContinuityPersistence(updateSignals) + return + } + + updateSignals() +} + +function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOptions): void { + if (!shouldContinueHydration(options)) return + + applyHydratedSignals(CONTENT_STATE_RESET, options) +} + +/** + * Hydrate a live Web SDK from public browser handoff state. + * + * @param sdk - Live Web SDK instance to hydrate. + * @param state - Public browser handoff state. + * + * @public + */ +export async function hydrateOptimizationHandoffState( + sdk: OptimizationHandoffHydrationTarget, + state: OptimizationHandoff['state'], + options: HandoffStateHydrationOptions = {}, +): Promise { + latestHandoffStateHydration += 1 + const hydration = latestHandoffStateHydration + + if (!state) { + applySuccessfulEmptyHandoffHydration(options) + return + } + + const hasChanges = hasOptimizationSelectionStateField(state, 'changes') + const hasProfile = hasOptimizationSelectionStateField(state, 'profile') + const hasSelectedOptimizations = hasOptimizationSelectionStateField( + state, + 'selectedOptimizations', + ) + if (!hasChanges && !hasProfile && !hasSelectedOptimizations) { + applySuccessfulEmptyHandoffHydration(options) + return + } + + const inputState = mergeOptimizationSelectionState(CONTENT_STATE_RESET, state) + + const { + interceptors: { state: stateInterceptors }, + } = sdk + + const hydratedState = await stateInterceptors + .run(inputState, mergeOptimizationSelectionState) + .catch((error: unknown) => { + if (hydration === latestHandoffStateHydration) throw error + return undefined + }) + + if (hydratedState === undefined || hydration !== latestHandoffStateHydration) return + if (!shouldContinueHydration(options)) return + + const mergedState = mergeOptimizationSelectionState(inputState, hydratedState) + + applyHydratedSignals(mergedState, options) +} + +/** + * Hydrate a live Web SDK from a content-capable browser handoff. + * + * @param sdk - Live Web SDK instance to hydrate. + * @param handoff - Content handoff produced by server, static, or edge rendering. + * + * @public + */ +export async function hydrateOptimizationHandoff( + sdk: OptimizationHandoffHydrationTarget, + handoff: OptimizationHandoff, + options: { readonly routeKey?: string; readonly isCurrent?: () => boolean } = {}, +): Promise { + assertOptimizationCacheSafety(handoff) + const routeKey = + options.routeKey ?? + (typeof window === 'undefined' ? '/' : `${window.location.pathname}${window.location.search}`) + const initialization = getHandoffInitialization(handoff, routeKey) + if (handoff.replay !== undefined && handoff.replay.routeKey !== routeKey) return + initialization.hydration ??= hydrateOptimizationHandoffState(sdk, handoff.state, { + isCurrent: () => initialization.isCurrent() && options.isCurrent?.() !== false, + suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), + }).catch((error: unknown) => { + initialization.hydration = undefined + throw error + }) + await initialization.hydration + if (options.isCurrent?.() === false) initialization.hydration = undefined +} diff --git a/packages/web/web-sdk/src/handoff.test.ts b/packages/web/web-sdk/src/handoff.test.ts index e642312bf..1e49f01b1 100644 --- a/packages/web/web-sdk/src/handoff.test.ts +++ b/packages/web/web-sdk/src/handoff.test.ts @@ -1,6 +1,7 @@ import { batch, signals } from '@contentful/optimization-core' import type { ChangeArray, + OptimizationData, Profile, SelectedOptimizationArray, } from '@contentful/optimization-core/api-schemas' @@ -13,8 +14,10 @@ import { hydrateOptimizationHandoffState, type ContentOptimizationHandoff, } from './handoff' -import { removeCookie } from './lib/cookies' +import { getCookie, removeCookie } from './lib/cookies' +import { createWebSnapshotRuntime } from './runtime' import LocalStore from './storage/LocalStore' +import { deferred } from './test/helpers' const config = { spaceId: 'key_123', @@ -73,7 +76,6 @@ function createContentHandoff( return { cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state, ...overrides, } @@ -199,6 +201,279 @@ describe('hydrateOptimizationHandoff', () => { expect('hydrateOptimizationHandoffState' in webBridgeSupport).toBe(false) }) + it('admits replay without waiting for delivery, persists its API ID and keeps early Insights attributable', async () => { + const sdk = new ContentfulOptimization({ + ...config, + defaults: { consent: true, persistenceConsent: true }, + }) + const response = deferred() + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockReturnValue(response.promise) + const insightsSent = createDeferred() + const insights = rs.spyOn(sdk.api.insights, 'sendBatchEvents').mockImplementation(async () => { + insightsSent.resolve() + return await Promise.resolve(true) + }) + const browserTransform = rs.spyOn(sdk.interceptors.event, 'run') + const events = [ + sdk.eventBuilder.buildIdentify({ userId: 'customer' }), + sdk.eventBuilder.buildPageView(), + ] + const handoff: ContentOptimizationHandoff = { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + profileId: 'api-issued-id', + replay: { routeKey: '/paired', events, locale: 'de-DE' }, + } + const ordinaryPage = rs.spyOn(sdk, 'page') + + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: true, + }) + expect(browserTransform).not.toHaveBeenCalled() + expect(sdk.states.profile.current).toBeUndefined() + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('api-issued-id') + await sdk.trackClick({ + componentId: 'displayed-variant', + experienceId: 'experience-id', + variantIndex: 1, + }) + const flushing = sdk.flush() + await insightsSent.promise + expect(insights.mock.calls[0]?.[0]).toMatchObject([ + { + profile: { id: 'api-issued-id' }, + events: [ + { componentId: 'displayed-variant', experienceId: 'experience-id', variantIndex: 1 }, + ], + }, + ]) + expect(deliver).toHaveBeenCalledWith( + { profileId: 'api-issued-id', events }, + { preflight: false, locale: 'de-DE' }, + ) + const liveReady = createDeferred() + const subscription = sdk.states.profile.subscribe((profile) => { + if (profile?.id === 'linked-api-id') liveReady.resolve() + }) + response.resolve({ profile: createProfile('linked-api-id'), changes, selectedOptimizations }) + await liveReady.promise + subscription.unsubscribe() + await flushing + expect(sdk.states.profile.current?.id).toBe('linked-api-id') + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('linked-api-id') + expect(ordinaryPage).not.toHaveBeenCalled() + }) + + it('shares state-only and delivery initialization without reseeding after a live response', async () => { + const seed = createProfile('api-issued-id') + const live = { ...seed, traits: { accepted: true } } + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: live, + changes: [], + selectedOptimizations: [], + }) + const handoff = createContentHandoff( + { profile: seed, changes, selectedOptimizations }, + { + cache: { scope: 'private-request' }, + profileId: seed.id, + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }, + ) + await hydrateOptimizationHandoff(sdk, handoff, { routeKey: '/paired' }) + const first = sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' }) + const repeat = sdk.hydrateAndTrackCurrentPage({ ...handoff }, { routeKey: '/paired' }) + expect(await first).toEqual({ accepted: true }) + expect(await repeat).toEqual({ accepted: true }) + await sdk.flush() + await hydrateOptimizationHandoff(sdk, handoff, { routeKey: '/paired' }) + await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' }) + expect(deliver).toHaveBeenCalledTimes(1) + expect(sdk.states.profile.current).toEqual(live) + expect(sdk.states.selectedOptimizations.current).toEqual([]) + }) + + it('reassesses consent, retaining the known ID in memory when persistence is denied', async () => { + const sdk = new ContentfulOptimization({ + ...config, + allowedEventTypes: [], + defaults: { persistenceConsent: false }, + }) + window.dispatchEvent(new Event('offline')) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const insights = rs.spyOn(sdk.api.insights, 'sendBatchEvents').mockResolvedValue(true) + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }) + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: false, + }) + sdk.consent({ events: true }) + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: true, + }) + await sdk.trackClick({ componentId: 'displayed-variant' }) + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBeUndefined() + expect(deliver).not.toHaveBeenCalled() + window.dispatchEvent(new Event('online')) + await sdk.flush() + expect(deliver).toHaveBeenCalledTimes(1) + expect(insights.mock.calls[0]?.[0][0]?.profile.id).toBe('api-issued-id') + }) + + it('rejects capacity without emitting a replacement page', async () => { + const sdk = new ContentfulOptimization({ + ...config, + defaults: { consent: true }, + queuePolicy: { offlineMaxEvents: 1 }, + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile') + const ordinaryPage = rs.spyOn(sdk, 'page') + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + replay: { + routeKey: '/paired', + events: [ + sdk.eventBuilder.buildIdentify({ userId: 'customer' }), + sdk.eventBuilder.buildPageView(), + ], + }, + }) + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: false, + }) + expect(deliver).not.toHaveBeenCalled() + expect(ordinaryPage).not.toHaveBeenCalled() + }) + + it('tracks new preparations and return visits while leaving retry ownership with Core', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + window.dispatchEvent(new Event('offline')) + const delivered = createDeferred() + let pages = 0 + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockImplementation(async (payload) => { + pages += payload.events.filter(({ type }) => type === 'page').length + if (pages === 4) delivered.resolve() + return await Promise.resolve({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + }) + const first = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { routeKey: '/one', events: [sdk.eventBuilder.buildPageView()] }, + }) + const second = { + ...first, + replay: { routeKey: '/one', events: [sdk.eventBuilder.buildPageView()] }, + } + await sdk.hydrateAndTrackCurrentPage(first, { routeKey: '/one' }) + await sdk.hydrateAndTrackCurrentPage(second, { routeKey: '/one' }) + await sdk.trackCurrentPage({ routeKey: '/two', buildPayload: () => ({}) }) + await sdk.hydrateAndTrackCurrentPage(first, { routeKey: '/one' }) + window.dispatchEvent(new Event('online')) + await delivered.promise + expect( + deliver.mock.calls + .flatMap(([payload]) => payload.events) + .filter(({ type }) => type === 'page'), + ).toHaveLength(4) + }) + + it('skips stale-route state and replay while permitting the current ordinary page', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes: [], + selectedOptimizations: [], + }) + const handoff = createContentHandoff( + { changes, selectedOptimizations }, + { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { routeKey: '/old', events: [sdk.eventBuilder.buildPageView()] }, + }, + ) + await hydrateOptimizationHandoff(sdk, handoff, { routeKey: '/current' }) + expect(sdk.states.selectedOptimizations.current).toBeUndefined() + await sdk.hydrateAndTrackCurrentPage(handoff, { + routeKey: '/current', + buildPayload: () => ({ properties: { title: 'current' } }), + }) + expect(deliver.mock.calls[0]?.[0].events).toMatchObject([ + { type: 'page', properties: { title: 'current' } }, + ]) + }) + + it.each(['reset', 'destroy'] as const)( + 'does not finish pending hydration after %s', + async (method) => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const waiting = createDeferred() + const started = createDeferred() + sdk.interceptors.state.add(async (state) => { + started.resolve() + await waiting.promise + return state + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile') + const handoff = createContentHandoff( + { profile: createProfile('api-issued-id') }, + { + cache: { scope: 'private-request' }, + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }, + ) + const initialization = sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' }) + await started.promise + sdk[method]() + waiting.resolve() + expect(await initialization).toEqual({ accepted: false }) + expect(sdk.states.profile.current).toBeUndefined() + expect(deliver).not.toHaveBeenCalled() + }, + ) + + it('keeps paired tracking inert in a snapshot runtime', async () => { + const runtime = createWebSnapshotRuntime() + expect( + await runtime.hydrateAndTrackCurrentPage({ cache: { scope: 'static' } }, { routeKey: '/' }), + ).toEqual({ accepted: false }) + }) + + it('preserves ordinary online errors and allows another page attempt when replay is absent', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const failure = new Error('Experience unavailable') + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockRejectedValueOnce(failure) + .mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const handoff = createContentHandoff(undefined) + await expect(sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/ordinary' })).rejects.toBe( + failure, + ) + expect( + (await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/ordinary' })).accepted, + ).toBe(true) + expect(deliver).toHaveBeenCalledTimes(2) + }) + it('hydrates selection state without clearing existing profile continuity', async () => { const existingProfile = createProfile('existing-profile') const sdk = new ContentfulOptimization({ @@ -417,7 +692,7 @@ describe('hydrateOptimizationHandoff', () => { }), ), ).rejects.toThrow( - 'Profile state should not be included in public or static optimization caches.', + 'Profile state, identity and replay must not be included in public or static optimization caches.', ) expect(sdk.states.profile.current).toBeUndefined() @@ -508,19 +783,4 @@ describe('hydrateOptimizationHandoff', () => { await first expect(sdk.states.profile.current).toEqual(secondProfile) }) - - it('rejects analytics-only handoffs', async () => { - const sdk = new ContentfulOptimization(config) - - await expect( - Reflect.apply(hydrateOptimizationHandoff, undefined, [ - sdk, - { - cache: { scope: 'static' }, - hydration: 'analytics-only', - initialPageEvent: 'skip', - }, - ]), - ).rejects.toThrow('content optimization handoffs') - }) }) diff --git a/packages/web/web-sdk/src/handoff.ts b/packages/web/web-sdk/src/handoff.ts index 2efb7c019..dbafa53d4 100644 --- a/packages/web/web-sdk/src/handoff.ts +++ b/packages/web/web-sdk/src/handoff.ts @@ -4,19 +4,7 @@ * @packageDocumentation */ -import { - assertOptimizationCacheSafety, - batch, - hasOptimizationSelectionStateField, - mergeOptimizationSelectionState, - signals, - type OptimizationHandoff, - type OptimizationSelectionState, -} from '@contentful/optimization-core' -import { - preserveProfilelessHandoffDurableContinuity, - suppressDurableContinuityPersistence, -} from './storage/durableContinuityPersistence' +import type { LifecycleInterceptors, OptimizationHandoff } from '@contentful/optimization-core' /** * Browser content hydration policy for already-rendered optimized content. @@ -40,8 +28,6 @@ export type OptimizationHydrationMode = ContentOptimizationHydrationMode | 'anal export interface ContentOptimizationHandoff extends OptimizationHandoff { /** Initial content hydration mode. */ readonly hydration: ContentOptimizationHydrationMode - /** Whether the browser owns the initial page event for this route. */ - readonly initialPageEvent: 'emit' | 'skip' } /** @@ -52,8 +38,6 @@ export interface ContentOptimizationHandoff extends OptimizationHandoff { export interface AnalyticsOptimizationHandoff extends OptimizationHandoff { /** Analytics-only handoffs never control content presentation. */ readonly hydration: 'analytics-only' - /** Whether the browser owns the initial page event for this route. */ - readonly initialPageEvent: 'emit' | 'skip' } /** @@ -63,15 +47,6 @@ export interface AnalyticsOptimizationHandoff extends OptimizationHandoff { */ export type BrowserOptimizationHandoff = ContentOptimizationHandoff | AnalyticsOptimizationHandoff -type BrowserOptimizationHandoffState = NonNullable - -interface BrowserHandoffStateInterceptorRunner { - readonly run: ( - state: BrowserOptimizationHandoffState, - merge?: typeof mergeOptimizationSelectionState, - ) => Promise -} - /** * @internal */ @@ -80,209 +55,13 @@ export interface HandoffStateHydrationOptions { readonly suppressDurableContinuityPersistence?: boolean } -interface HydratedSignalUpdate { - readonly hasChanges: boolean - readonly hasProfile: boolean - readonly hasSelectedOptimizations: boolean - readonly options: HandoffStateHydrationOptions - readonly state: BrowserOptimizationHandoffState -} - -const CONTENT_STATE_RESET: OptimizationSelectionState = { - changes: undefined, - selectedOptimizations: undefined, -} - -/** - * Minimal Web SDK shape required for browser handoff hydration. - * - * @public - */ +/** Minimal Web SDK shape required for browser handoff hydration. @public */ export interface OptimizationHandoffHydrationTarget { - readonly interceptors: { - readonly state: unknown - } -} - -const CONTENT_HYDRATION_MODES: readonly ContentOptimizationHydrationMode[] = [ - 'preserve-server', - 'client-only-hidden-until-ready', -] - -let latestHandoffStateHydration = 0 - -function assertInitialPageEvent( - initialPageEvent: unknown, -): asserts initialPageEvent is 'emit' | 'skip' { - if (initialPageEvent === 'emit' || initialPageEvent === 'skip') return - - throw new TypeError('Optimization handoff requires initialPageEvent to be "emit" or "skip".') + readonly interceptors: Pick } -function assertContentHandoff( - handoff: ContentOptimizationHandoff, -): asserts handoff is ContentOptimizationHandoff { - if (CONTENT_HYDRATION_MODES.includes(handoff.hydration)) return - - throw new TypeError('hydrateOptimizationHandoff only accepts content optimization handoffs.') -} - -function hasBrowserHandoffStateInterceptorRunner( - value: unknown, -): value is BrowserHandoffStateInterceptorRunner { - if (value === null || typeof value !== 'object') return false - if (!('run' in value)) return false - - return typeof value.run === 'function' -} - -function shouldContinueHydration(options: HandoffStateHydrationOptions): boolean { - return options.isCurrent?.() !== false -} - -/** - * @internal - */ -export function shouldPreserveDurableContinuity(handoff: BrowserOptimizationHandoff): boolean { - return ( - (handoff.cache.scope === 'public-permutation' || handoff.cache.scope === 'static') && - handoff.state?.profile === undefined - ) -} - -function applyHydratedSignals({ - hasChanges, - hasProfile, - hasSelectedOptimizations, - options, - state, -}: HydratedSignalUpdate): void { - const { changes, profile, selectedOptimizations } = state - const { - changes: changesSignal, - experienceRequestState, - profile: profileSignal, - selectedOptimizations: selectedOptimizationsSignal, - } = signals - - const updateSignals = (): void => { - batch(() => { - if (hasChanges) changesSignal.value = changes - if (hasProfile) profileSignal.value = profile - if (hasSelectedOptimizations) selectedOptimizationsSignal.value = selectedOptimizations - - experienceRequestState.value = { status: 'success' } - }) - } - - if (options.suppressDurableContinuityPersistence === true) { - preserveProfilelessHandoffDurableContinuity() - suppressDurableContinuityPersistence(updateSignals) - return - } - - updateSignals() -} - -function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOptions): void { - if (!shouldContinueHydration(options)) return - - applyHydratedSignals({ - hasChanges: true, - hasProfile: false, - hasSelectedOptimizations: true, - options, - state: CONTENT_STATE_RESET, - }) -} - -async function hydrateOptimizationHandoffStateInternal( - sdk: OptimizationHandoffHydrationTarget, - state: BrowserOptimizationHandoff['state'], - options: HandoffStateHydrationOptions = {}, -): Promise { - latestHandoffStateHydration += 1 - const hydration = latestHandoffStateHydration - - if (!state) { - applySuccessfulEmptyHandoffHydration(options) - return - } - - const hasChanges = hasOptimizationSelectionStateField(state, 'changes') - const hasProfile = hasOptimizationSelectionStateField(state, 'profile') - const hasSelectedOptimizations = hasOptimizationSelectionStateField( - state, - 'selectedOptimizations', - ) - if (!hasChanges && !hasProfile && !hasSelectedOptimizations) { - applySuccessfulEmptyHandoffHydration(options) - return - } - - const inputState = mergeOptimizationSelectionState(CONTENT_STATE_RESET, state) - - const stateInterceptors: unknown = sdk.interceptors.state - if (!hasBrowserHandoffStateInterceptorRunner(stateInterceptors)) { - throw new Error('Contentful Optimization SDK instance does not expose state hydration support.') - } - - const hydratedState = await stateInterceptors - .run(inputState, mergeOptimizationSelectionState) - .catch((error: unknown) => { - if (hydration === latestHandoffStateHydration) throw error - return undefined - }) - - if (hydratedState === undefined || hydration !== latestHandoffStateHydration) return - if (!shouldContinueHydration(options)) return - - const mergedState = mergeOptimizationSelectionState(inputState, hydratedState) - - applyHydratedSignals({ - hasChanges: hasOptimizationSelectionStateField(mergedState, 'changes'), - hasProfile: hasOptimizationSelectionStateField(mergedState, 'profile'), - hasSelectedOptimizations: hasOptimizationSelectionStateField( - mergedState, - 'selectedOptimizations', - ), - options, - state: mergedState, - }) -} - -/** - * Hydrate a live Web SDK from public browser handoff state. - * - * @param sdk - Live Web SDK instance to hydrate. - * @param state - Public browser handoff state. - * - * @public - */ -export async function hydrateOptimizationHandoffState( - sdk: OptimizationHandoffHydrationTarget, - state: BrowserOptimizationHandoff['state'], - options: HandoffStateHydrationOptions = {}, -): Promise { - await hydrateOptimizationHandoffStateInternal(sdk, state, options) -} - -/** - * Hydrate a live Web SDK from a content-capable browser handoff. - * - * @param sdk - Live Web SDK instance to hydrate. - * @param handoff - Content handoff produced by server, static, or edge rendering. - * - * @public - */ -export async function hydrateOptimizationHandoff( - sdk: OptimizationHandoffHydrationTarget, - handoff: ContentOptimizationHandoff, -): Promise { - assertContentHandoff(handoff) - assertInitialPageEvent(handoff.initialPageEvent) - assertOptimizationCacheSafety(handoff) - await hydrateOptimizationHandoffStateInternal(sdk, handoff.state, { - suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), - }) -} +export { + hydrateOptimizationHandoff, + hydrateOptimizationHandoffState, + shouldPreserveDurableContinuity, +} from './handoff-internal' diff --git a/packages/web/web-sdk/src/lib/cookies.test.ts b/packages/web/web-sdk/src/lib/cookies.test.ts index 6094aa186..260a6ff30 100644 --- a/packages/web/web-sdk/src/lib/cookies.test.ts +++ b/packages/web/web-sdk/src/lib/cookies.test.ts @@ -114,6 +114,30 @@ describe('setCookie', () => { }) describe('removeCookie', () => { + it('uses the configured scope and policy for both writing and deletion', () => { + const written: string[] = [] + Object.defineProperty(document, 'cookie', { + configurable: true, + get: () => '', + set: (value: string) => { + written.push(value) + }, + }) + const attributes = { + domain: 'example.com', + path: '/shop', + sameSite: 'none', + secure: true, + } as const + setCookie('foo', 'bar', attributes) + removeCookie('foo', attributes) + + expect(written).toEqual([ + 'foo=bar; Path=/shop; Domain=example.com; SameSite=none; Secure', + 'foo=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/shop; Domain=example.com; SameSite=none; Secure', + ]) + }) + it('writes an expired cookie at the epoch', () => { let written = '' Object.defineProperty(document, 'cookie', { diff --git a/packages/web/web-sdk/src/lib/cookies.ts b/packages/web/web-sdk/src/lib/cookies.ts index a198751a2..e6208ff83 100644 --- a/packages/web/web-sdk/src/lib/cookies.ts +++ b/packages/web/web-sdk/src/lib/cookies.ts @@ -21,6 +21,12 @@ export interface CookieAttributes { * Determines the expiration date of the cookie as the number of days until the cookie expires. */ expires?: number + /** Cookie path; defaults to `/`. */ + path?: string + /** Cross-site cookie policy. */ + sameSite?: 'lax' | 'strict' | 'none' + /** Require HTTPS when writing the cookie. */ + secure?: boolean } export const getCookie = (name: string): string | undefined => { @@ -37,25 +43,29 @@ export const getCookie = (name: string): string | undefined => { return undefined } +function serializeCookieScope(attributes?: CookieAttributes): string { + let cookie = `; Path=${attributes?.path ?? '/'}` + if (attributes?.domain) { + cookie += `; Domain=${attributes.domain}` + } + if (attributes?.sameSite) cookie += `; SameSite=${attributes.sameSite}` + if (attributes?.secure) cookie += '; Secure' + return cookie +} + export const setCookie = (name: string, value: string, attributes?: CookieAttributes): void => { if (typeof document === 'undefined') return - let cookie = `${name}=${value}; Path=/` - + let cookie = `${name}=${value}${serializeCookieScope(attributes)}` if (typeof attributes?.expires === 'number' && Number.isFinite(attributes.expires)) { cookie += `; Expires=${new Date(Date.now() + attributes.expires * MS_IN_DAY).toUTCString()}` } - if (attributes?.domain) { - cookie += `; Domain=${attributes.domain}` - } document.cookie = cookie } -export const removeCookie = (name: string, attributes?: Pick): void => { +export const removeCookie = (name: string, attributes?: CookieAttributes): void => { if (typeof document === 'undefined') return - document.cookie = `${name}=; Expires=${EXPIRED_UTC}; Path=/${ - attributes?.domain ? `; Domain=${attributes.domain}` : '' - }` + document.cookie = `${name}=; Expires=${EXPIRED_UTC}${serializeCookieScope(attributes)}` } diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index 28819d1a8..28d1c5daa 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -27,7 +27,7 @@ export * from '@contentful/optimization-core/runtime' * * @internal */ -type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' +type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' | 'hydrateAndTrackCurrentPage' type ManagedEntryFetchMembers = | 'fetchContentfulEntries' | 'fetchContentfulEntry' @@ -96,5 +96,6 @@ export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOp prefetchManagedEntries: rejectSnapshotManagedEntryFetch, tracking: NOOP_TRACKING, trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), + hydrateAndTrackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), }) } diff --git a/packages/web/web-sdk/src/web-components/index.test.ts b/packages/web/web-sdk/src/web-components/index.test.ts index e93be1364..313eff0c0 100644 --- a/packages/web/web-sdk/src/web-components/index.test.ts +++ b/packages/web/web-sdk/src/web-components/index.test.ts @@ -225,6 +225,7 @@ function createSdk( enableElement: () => undefined, }, trackCurrentPage: resolveAccepted, + hydrateAndTrackCurrentPage: resolveAccepted, trackView: resolveAccepted, } From d6c267d4392ac579f107e1d2699ece7631755454 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Wed, 7 Oct 2026 11:58:26 +0200 Subject: [PATCH 04/29] refactor(bridge)!: remove native preflight configuration --- .../contentful/optimization/core/OptimizationConfig.kt | 5 +---- .../optimization/core/OptimizationConfigTest.kt | 2 -- .../ContentfulOptimization/Core/OptimizationConfig.swift | 9 +-------- .../OptimizationClientTests.swift | 4 +--- packages/universal/optimization-js-bridge/src/index.ts | 2 -- 5 files changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt index 21862a53b..99988dbb6 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt @@ -26,20 +26,17 @@ public data class OptimizationApiConfig( val experienceBaseUrl: String? = null, val insightsBaseUrl: String? = null, val enabledFeatures: List? = null, - val preflight: Boolean? = null, ) { internal fun isEmpty(): Boolean = experienceBaseUrl == null && insightsBaseUrl == null && - enabledFeatures == null && - preflight == null + enabledFeatures == null internal fun toJSONObject(): JSONObject { val obj = JSONObject() experienceBaseUrl?.let { obj.put("experienceBaseUrl", it) } insightsBaseUrl?.let { obj.put("insightsBaseUrl", it) } enabledFeatures?.let { obj.put("enabledFeatures", org.json.JSONArray(it)) } - preflight?.let { obj.put("preflight", it) } return obj } } diff --git a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt index abc88ab8d..c487d582e 100644 --- a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt +++ b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt @@ -90,7 +90,6 @@ class OptimizationConfigTest { experienceBaseUrl = "http://localhost:8000/experience/", insightsBaseUrl = "http://localhost:8000/insights/", enabledFeatures = listOf("audiences", "experiences"), - preflight = true, ), logLevel = OptimizationLogLevel.debug, ) @@ -105,7 +104,6 @@ class OptimizationConfigTest { assertEquals("http://localhost:8000/insights/", api.getString("insightsBaseUrl")) assertEquals("audiences", enabledFeatures.getString(0)) assertEquals("experiences", enabledFeatures.getString(1)) - assertEquals(true, api.getBoolean("preflight")) assertEquals("debug", json.getString("logLevel")) } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift index ba0b78471..b5dc06f2e 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift @@ -55,25 +55,21 @@ public struct OptimizationApiConfig { public let experienceBaseUrl: String? public let insightsBaseUrl: String? public let enabledFeatures: [String]? - public let preflight: Bool? public init( experienceBaseUrl: String? = nil, insightsBaseUrl: String? = nil, - enabledFeatures: [String]? = nil, - preflight: Bool? = nil + enabledFeatures: [String]? = nil ) { self.experienceBaseUrl = experienceBaseUrl self.insightsBaseUrl = insightsBaseUrl self.enabledFeatures = enabledFeatures - self.preflight = preflight } var isEmpty: Bool { experienceBaseUrl == nil && insightsBaseUrl == nil && enabledFeatures == nil - && preflight == nil } func toDictionary() -> [String: Any] { @@ -87,9 +83,6 @@ public struct OptimizationApiConfig { if let enabledFeatures { dict["enabledFeatures"] = enabledFeatures } - if let preflight { - dict["preflight"] = preflight - } return dict } } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift index aac975626..a68f2c8c1 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/OptimizationClientTests.swift @@ -49,8 +49,7 @@ final class OptimizationClientTests: XCTestCase { api: OptimizationApiConfig( experienceBaseUrl: "http://localhost:8000/experience/", insightsBaseUrl: "http://localhost:8000/insights/", - enabledFeatures: ["audiences", "experiences"], - preflight: true + enabledFeatures: ["audiences", "experiences"] ), logLevel: .debug ) @@ -65,7 +64,6 @@ final class OptimizationClientTests: XCTestCase { XCTAssertEqual(api?["experienceBaseUrl"] as? String, "http://localhost:8000/experience/") XCTAssertEqual(api?["insightsBaseUrl"] as? String, "http://localhost:8000/insights/") XCTAssertEqual(api?["enabledFeatures"] as? [String], ["audiences", "experiences"]) - XCTAssertEqual(api?["preflight"] as? Bool, true) XCTAssertEqual(dict["logLevel"] as? String, "debug") } diff --git a/packages/universal/optimization-js-bridge/src/index.ts b/packages/universal/optimization-js-bridge/src/index.ts index 4fb6b7ba9..5a76272f2 100644 --- a/packages/universal/optimization-js-bridge/src/index.ts +++ b/packages/universal/optimization-js-bridge/src/index.ts @@ -74,7 +74,6 @@ interface BridgeConfig { experienceBaseUrl?: CoreApiConfig['experienceBaseUrl'] insightsBaseUrl?: CoreApiConfig['insightsBaseUrl'] enabledFeatures?: CoreApiConfig['enabledFeatures'] - preflight?: CoreApiConfig['preflight'] } locale?: string logLevel?: CoreStatefulConfig['logLevel'] @@ -382,7 +381,6 @@ const bridge: Bridge = { experienceBaseUrl: config.api?.experienceBaseUrl, insightsBaseUrl: config.api?.insightsBaseUrl, enabledFeatures: config.api?.enabledFeatures, - preflight: config.api?.preflight, }, queuePolicy: { ...config.queuePolicy, From 539bf04577f40742317ac15bf12af6c6bf427775 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Wed, 7 Oct 2026 12:41:53 +0200 Subject: [PATCH 05/29] feat(react-web)!: delegate paired handoffs to the Web runtime --- AGENTS.md | 8 + lib/build-tools/AGENTS.md | 3 + packages/AGENTS.md | 3 + packages/node/node-sdk/AGENTS.md | 3 + packages/react-native-sdk/AGENTS.md | 3 + packages/universal/api-client/AGENTS.md | 3 + packages/universal/api-schemas/AGENTS.md | 3 + packages/universal/core-sdk/AGENTS.md | 3 + packages/web/AGENTS.md | 3 + packages/web/frameworks/nextjs-sdk/AGENTS.md | 3 + .../web/frameworks/react-web-sdk/AGENTS.md | 3 + .../web/frameworks/react-web-sdk/package.json | 12 +- .../src/auto-page/useAutoPageEmitter.test.tsx | 78 +----- .../src/auto-page/useAutoPageEmitter.ts | 86 ++----- .../web/frameworks/react-web-sdk/src/index.ts | 1 - .../optimized-entry/OptimizedEntry.test.tsx | 1 - ...ptimizationProvider.onStatesReady.test.tsx | 41 +++- .../src/provider/OptimizationProvider.tsx | 64 +++-- .../root/OptimizationAnalyticsRoot.test.tsx | 102 +------- .../src/root/OptimizationAnalyticsRoot.tsx | 28 +-- .../src/root/OptimizationRoot.test.tsx | 226 ++++++++---------- .../src/root/OptimizationRoot.tsx | 155 +++--------- .../src/router/next-app.test.tsx | 76 +++--- .../react-web-sdk/src/router/next-app.tsx | 9 +- .../src/router/next-pages.test.tsx | 33 --- .../react-web-sdk/src/router/next-pages.tsx | 9 +- .../src/router/react-router.test.tsx | 2 - .../src/router/tanstack-router.test.tsx | 5 - .../react-web-sdk/src/test/sdkTestUtils.tsx | 12 +- packages/web/preview-panel/AGENTS.md | 3 + packages/web/web-sdk/AGENTS.md | 3 + 31 files changed, 331 insertions(+), 653 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 182e8ecdc..43104b32d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,14 @@ explicitly requests it. impact crosses package families or is uncertain, use aggregate `pnpm size:report` for the complete inventory, then `pnpm size:check` after classification. - On failure, report the command, package or bundle, budget, actual size, and delta. +- For an explicitly approved increase to an exceeded budget, or a request to normalize budgets, + set each budget in the authorized scope to `ceil((measuredGzipBytes + 100) / 100) * 100`: add + 100 bytes to the measured gzip size, then round up to a multiple of 100 bytes. Use the + completed build's `size:report` measurement, including reachable local chunks/assets counted by the + checker; do not calculate from the previous budget or an isolated entry file. Normalization + applies to every requested entry, including budgets that decrease or already satisfy their + limit. This calculation does not authorize additional budget changes or waive the approval, + failure-classification, scope, and validation rules in this section. - Allowed remediation is limited to confirmed current-change regressions, dependency/import mistakes, and measurement issues. Every remediation must preserve maintainability: do not make code harder to read, type, test, debug, or change just to reduce bytes. Prefer fixes such as diff --git a/lib/build-tools/AGENTS.md b/lib/build-tools/AGENTS.md index 2a6aefed4..b8c36d831 100644 --- a/lib/build-tools/AGENTS.md +++ b/lib/build-tools/AGENTS.md @@ -11,6 +11,9 @@ enforcement. - Bundle-size thresholds are read from package `package.json` under `buildTools.bundleSize.gzipBudgets`; the root `Bundle size` policy controls whether they may be changed. +- For approved increases to exceeded budgets or requested normalization, set each authorized budget + to `ceil((measuredGzipBytes + 100) / 100) * 100` under the root + [Bundle size](../../AGENTS.md#bundle-size) measurement and approval rules. ## Commands diff --git a/packages/AGENTS.md b/packages/AGENTS.md index beca26995..3ec64de5d 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -76,6 +76,9 @@ For pnpm-managed packages with matching scripts, use `pnpm --filter AutoPagePayload }): null { useAutoPageEmitter({ enabled, - initialPageEvent, routeKey, buildPayload: buildPayload ?? ((): AutoPagePayload => payload ?? {}), }) @@ -35,21 +28,7 @@ function TestAutoPageEmitter({ return null } -function TestSkipOnlyAutoPageEmitter({ routeKey }: { routeKey: string }): null { - useAutoPageEmitter({ - enabled: true, - initialPageEvent: 'skip', - routeKey, - }) - - return null -} - describe('useAutoPageEmitter', () => { - beforeEach(() => { - resetAutoPageEmitterState() - }) - it('emits on first eligible render', async () => { const page = rs.fn(async (_payload?: AutoPagePayload) => { await Promise.resolve() @@ -228,61 +207,6 @@ describe('useAutoPageEmitter', () => { await second.unmount() }) - it('skips only the initial route when server rendering already emitted its page event', async () => { - const page = rs.fn(async (_payload?: AutoPagePayload) => { - await Promise.resolve() - return undefined - }) - const buildPayload = rs.fn((): AutoPagePayload => ({})) - const sdk = createOptimizationSdk({ page }) - const rendered = await renderWithOptimizationProviders( - , - sdk, - ) - - expect(buildPayload).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - - await rendered.rerender( - , - ) - - expect(buildPayload).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) - - await rendered.rerender( - , - ) - - expect(buildPayload).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenCalledTimes(2) - - await rendered.unmount() - }) - - it('marks a skipped initial route without a payload builder', async () => { - const trackCurrentPage = rs.fn(async () => { - await Promise.resolve() - return { accepted: true as const } - }) - const sdk = createOptimizationSdk({ trackCurrentPage }) - const rendered = await renderWithOptimizationProviders( - , - sdk, - ) - - expect(trackCurrentPage).toHaveBeenCalledWith({ - initialPageEvent: 'skip', - routeKey: '/', - }) - - await rendered.unmount() - }) - it('does not emit when disabled', async () => { const page = rs.fn(async () => { await Promise.resolve() diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts index 3d76e5ae1..614607e10 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from 'react' -import { useOptimization } from '../hooks/useOptimization' +import type { OptimizationHandoff } from '@contentful/optimization-web/core-sdk' +import { useEffect } from 'react' +import { useOptimizationContext } from '../hooks/useOptimization' import { useConsentState } from '../hooks/useOptimizationState' import type { AutoPagePayload } from './types' @@ -7,86 +8,31 @@ export interface AutoPageEmissionMetadata { readonly isInitialEmission: boolean } -export type InitialAutoPageEvent = 'emit' | 'skip' - export interface UseAutoPageEmitterArgs { - /** - * When `false` the emitter is inert. Adapters that depend on a router being - * ready (e.g. Next.js Pages router) should gate on their readiness signal - * here. - */ readonly enabled: boolean - /** - * Stable string identity for the current route. Consecutive emissions with - * the same `routeKey` are deduplicated, which also suppresses StrictMode's - * double-effect invocations. - */ readonly routeKey: string - /** - * Controls the first eligible route emission. SSR integrations can use - * `skip` when the server already emitted the mounted route's page event. - * Later client-side route changes still emit when a payload builder is - * available. - */ - readonly initialPageEvent?: InitialAutoPageEvent - /** - * Builds the page event payload to emit. Required for emitted routes and not - * called for skip-only initial route marking. - */ + readonly handoff?: OptimizationHandoff readonly buildPayload?: (metadata: AutoPageEmissionMetadata) => AutoPagePayload } -/** - * Emit a page event when the route changes. - * - * The hook is intentionally narrow: it owns dedup and emission only. Each - * router adapter is responsible for building the finished payload and passing - * it through `buildPayload`. - * - * @internal - */ +/** Delegate router invocation to the existing Web tracking/admission operation. @internal */ export function useAutoPageEmitter({ enabled, - initialPageEvent = 'emit', routeKey, + handoff, buildPayload, }: UseAutoPageEmitterArgs): void { - const sdk = useOptimization() + const { sdk, isLive } = useOptimizationContext() const consent = useConsentState() - const skippedInitialRouteKey = useRef(undefined) useEffect(() => { - if (!enabled) { - return - } - - if (skippedInitialRouteKey.current === undefined) { - skippedInitialRouteKey.current = initialPageEvent === 'skip' ? routeKey : null - } - - const currentInitialPageEvent = skippedInitialRouteKey.current === routeKey ? 'skip' : 'emit' - - if (skippedInitialRouteKey.current !== routeKey) { - skippedInitialRouteKey.current = null - } - - if (currentInitialPageEvent === 'skip') { - void sdk.trackCurrentPage({ initialPageEvent: 'skip', routeKey }).catch(() => undefined) - return - } - - if (buildPayload === undefined) return - - void sdk - .trackCurrentPage({ - buildPayload, - initialPageEvent: 'emit', - routeKey, - }) - .catch(() => undefined) - }, [buildPayload, consent, enabled, initialPageEvent, routeKey, sdk]) -} - -export function resetAutoPageEmitterState(): void { - // Current-page state is owned by each Web SDK instance. + if (!enabled || isLive === false || sdk === undefined) return + if (handoff === undefined && buildPayload === undefined) return + + const emission = + handoff === undefined + ? sdk.trackCurrentPage({ routeKey, buildPayload: buildPayload ?? (() => ({})) }) + : sdk.hydrateAndTrackCurrentPage(handoff, { routeKey, buildPayload }) + void emission.catch(() => undefined) + }, [buildPayload, consent, enabled, handoff, isLive, routeKey, sdk]) } diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index 966143867..26ee3f066 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -16,7 +16,6 @@ export type { AutoPagePayloadOptions, AutoPageRouteState, } from './auto-page/types' -export type { InitialAutoPageEvent } from './auto-page/useAutoPageEmitter' export type { BeforeInitialPageClient, BeforeInitialPageOptions, diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index dd552d42b..6c6893e6a 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx @@ -205,7 +205,6 @@ describe('OptimizedEntry', () => { return { cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state: createServerOptimizationState(), ...overrides, } diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index c395cbfc1..9605981dc 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -4,11 +4,11 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { InterceptorManager } from '@contentful/optimization-web/core-sdk' import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' -import { beforeEach, describe, expect, it, rs } from '@rstest/core' +import { describe, expect, it, rs } from '@rstest/core' import { act, type ReactElement, useContext } from 'react' import { createRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' -import { resetAutoPageEmitterState, useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' +import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' import type { OptimizationContextValue } from '../context/OptimizationContext' import { OptimizationHydrationContext } from '../context/OptimizationHydrationContext' import { @@ -117,7 +117,6 @@ function createContentHandoff( return { cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state: createServerOptimizationState(profileId), ...overrides, } @@ -192,10 +191,6 @@ async function renderClientAsync( } describe('OptimizationProvider onStatesReady', () => { - beforeEach(() => { - resetAutoPageEmitterState() - }) - it('accepts onStatesReady on OptimizationProvider and OptimizationRoot props', () => { const onStatesReady = rs.fn() const providerProps: OptimizationProviderProps = { @@ -321,6 +316,28 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) + it('prefetches only entries absent from the supplied handoff', async () => { + const baselineEntry = createTestEntry('supplied-entry') + const prefetch = rs + .spyOn(ContentfulOptimization.prototype, 'prefetchManagedEntries') + .mockResolvedValue([]) + const handoff = createContentHandoff('f0837d7dc6344c36a3a0a06c4cde754b', { + entries: [{ entryId: baselineEntry.sys.id, baselineEntry }], + }) + const rendered = await renderClientAsync( + +
+ , + ) + expect(prefetch).toHaveBeenCalledWith(['next-entry']) + rendered.unmount() + prefetch.mockRestore() + }) + it('makes standalone hydration visible to provider children without a handoff', async () => { let capturedHydration: unknown @@ -394,6 +411,9 @@ describe('OptimizationProvider onStatesReady', () => { }) it('retains a usable owned runtime and surfaces the error when initial handoff apply fails', async () => { + const page = rs + .spyOn(ContentfulOptimization.prototype, 'page') + .mockResolvedValue({ accepted: true }) const hydrationError = new Error('owned handoff apply failed') const hydration = rs .spyOn(InterceptorManager.prototype, 'run') @@ -418,7 +438,7 @@ describe('OptimizationProvider onStatesReady', () => { const sdk = requireOptimizationSdk(context.sdk) expect(sdk).toBeInstanceOf(ContentfulOptimization) await expect( - sdk.trackCurrentPage({ initialPageEvent: 'skip', routeKey: '/failed-handoff' }), + sdk.trackCurrentPage({ routeKey: '/failed-handoff', buildPayload: () => ({}) }), ).resolves.toEqual({ accepted: true }) expect(destroy).not.toHaveBeenCalled() @@ -426,6 +446,7 @@ describe('OptimizationProvider onStatesReady', () => { expect(destroy).toHaveBeenCalledTimes(1) hydration.mockRestore() destroy.mockRestore() + page.mockRestore() }) it('keeps injected initial-handoff failure behavior unchanged', async () => { @@ -714,7 +735,9 @@ describe('OptimizationProvider onStatesReady', () => { , ) - }).toThrow('Profile state should not be included in public or static optimization caches.') + }).toThrow( + 'Profile state, identity and replay must not be included in public or static optimization caches.', + ) expect(childRendered).toBe(false) expect(window.contentfulOptimization).toBeUndefined() }) 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 05454003e..442057d33 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -59,6 +59,8 @@ interface OptimizationHandoffProps { * Server/static/edge Optimization handoff to apply before provider children mount. */ readonly handoff?: ContentOptimizationHandoff + /** Current router route; defaults to the visible browser pathname/search. */ + readonly routeKey?: string /** * Overrides the content hydration presentation mode published to optimized entries. */ @@ -119,6 +121,7 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider onStatesReady: _onStatesReady, sdk: _sdk, handoff: _handoff, + routeKey: _routeKey, hydration: _hydration, prefetchManagedEntries: _prefetchManagedEntries, trackEntryInteraction, @@ -153,12 +156,21 @@ async function initializeServerOptimizationState( sdkBinding: ProviderSdkBinding, handoff: ContentOptimizationHandoff, onStatesReady: OnStatesReady | undefined, - retainOwnedSdkOnHydrationError: boolean, + { + retainOwnedSdkOnHydrationError, + routeKey, + isCurrent, + }: { + readonly retainOwnedSdkOnHydrationError: boolean + readonly routeKey?: string + readonly isCurrent: () => boolean + }, ): Promise { try { const hydrationResult: unknown = Reflect.apply(hydrateOptimizationHandoff, undefined, [ sdkBinding.sdk, handoff, + { routeKey, isCurrent }, ]) if (isPromiseLike(hydrationResult)) { @@ -173,6 +185,7 @@ async function initializeServerOptimizationState( throw error } + if (!isCurrent()) return { sdkBinding } try { return { sdkBinding: bindOnStatesReady(sdkBinding, onStatesReady) } } catch (error: unknown) { @@ -183,9 +196,10 @@ async function initializeServerOptimizationState( function initializeProviderSdk( props: OptimizationProviderProps, + sdkBinding: ProviderSdkBinding, + isCurrent: () => boolean, ): ProviderSdkInitialization | Promise { const ownsSdk = props.sdk === undefined - const sdkBinding = ownsSdk ? createOwnedSdkBinding(props) : createInjectedSdkBinding(props) if (props.handoff === undefined) { try { @@ -196,7 +210,11 @@ function initializeProviderSdk( } } - return initializeServerOptimizationState(sdkBinding, props.handoff, props.onStatesReady, ownsSdk) + return initializeServerOptimizationState(sdkBinding, props.handoff, props.onStatesReady, { + retainOwnedSdkOnHydrationError: ownsSdk, + routeKey: props.routeKey, + isCurrent, + }) } function isPromiseLike(value: T | Promise): value is Promise { @@ -251,7 +269,6 @@ function createPrefetchedManagedEntries( export function OptimizationProvider(props: OptimizationProviderProps): ReactElement { const { children } = props const initialPropsRef = useRef(props) - const hydratedHandoffRef = useRef(props.handoff) const liveLocale = props.sdk === undefined ? props.locale : undefined const [state, setState] = useState(() => ({ error: undefined, @@ -301,7 +318,15 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } try { - const initializedBinding = initializeProviderSdk(initialProps) + sdkBinding = + initialProps.sdk === undefined + ? createOwnedSdkBinding(initialProps) + : createInjectedSdkBinding(initialProps) + const initializedBinding = initializeProviderSdk( + initialProps, + sdkBinding, + () => !setupState.disposed, + ) if (!isPromiseLike(initializedBinding)) { setInitializedState(initializedBinding) @@ -332,12 +357,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return } - if (hydratedHandoffRef.current === handoff) { - return - } - let disposed = false - hydratedHandoffRef.current = handoff function setHydrationError(error: unknown): void { if (!disposed) { @@ -349,6 +369,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const hydrationResult: unknown = Reflect.apply(hydrateOptimizationHandoff, undefined, [ runtime, handoff, + { routeKey: props.routeKey, isCurrent: () => !disposed }, ]) if (isPromiseLike(hydrationResult)) { @@ -361,7 +382,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return () => { disposed = true } - }, [props.handoff, state.isLive, state.runtime]) + }, [props.handoff, props.routeKey, state.isLive, state.runtime]) useLayoutEffect(() => { if (!state.isLive || state.runtime === undefined || props.sdk !== undefined) { @@ -388,20 +409,25 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return } + const entries = props.prefetchManagedEntries.filter( + (entry) => + !prefetchedManagedEntries?.has( + getOptimizedEntrySourceKey(typeof entry === 'string' ? { entryId: entry } : entry), + ), + ) + if (entries.length === 0) return let disposed = false - void state.runtime - .prefetchManagedEntries(props.prefetchManagedEntries) - .catch((error: unknown) => { - if (!disposed) { - setState({ error: toError(error), isLive: true, runtime: state.runtime }) - } - }) + void state.runtime.prefetchManagedEntries(entries).catch((error: unknown) => { + if (!disposed) { + setState({ error: toError(error), isLive: true, runtime: state.runtime }) + } + }) return () => { disposed = true } - }, [props.prefetchManagedEntries, state.isLive, state.runtime]) + }, [prefetchedManagedEntries, props.prefetchManagedEntries, state.isLive, state.runtime]) const contextValue = useMemo( () => ({ diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx index b5807fb73..cfa225bfe 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx @@ -2,7 +2,7 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { AnalyticsOptimizationHandoff } from '@contentful/optimization-web/analytics' import { InterceptorManager, signals } from '@contentful/optimization-web/core-sdk' import { describe, expect, it, rs } from '@rstest/core' -import { act, StrictMode, type ReactElement } from 'react' +import { act, type ReactElement } from 'react' import { createRoot } from 'react-dom/client' import { useOptimization } from '../hooks/useOptimization' import { captureRenderError } from '../test/sdkTestUtils' @@ -20,7 +20,6 @@ const testConfig = { const analyticsHandoff: AnalyticsOptimizationHandoff = { cache: { scope: 'static' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { selectedOptimizations: [] }, } @@ -133,7 +132,6 @@ describe('OptimizationAnalyticsRoot', () => { expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: buildPagePayload, - initialPageEvent: 'emit', routeKey: '/segments/a', }) expect(resolveOptimizedEntry).not.toHaveBeenCalled() @@ -143,101 +141,6 @@ describe('OptimizationAnalyticsRoot', () => { resolveOptimizedEntry.mockRestore() }) - it('skips only the initially hydrated analytics route', async () => { - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockResolvedValue({ accepted: true }) - const buildPagePayload = rs.fn(() => ({})) - const handoff: AnalyticsOptimizationHandoff = { - ...analyticsHandoff, - initialPageEvent: 'skip', - } - - const rendered = await renderClientAsync( - -
- , - ) - - await rendered.rerender( - -
- , - ) - - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: buildPagePayload, - initialPageEvent: 'skip', - routeKey: '/', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - buildPayload: buildPagePayload, - initialPageEvent: 'emit', - routeKey: '/products', - }) - - rendered.unmount() - trackCurrentPage.mockRestore() - }) - - it('keeps a skipped initial analytics route skipped through StrictMode replay', async () => { - const page = rs.spyOn(ContentfulOptimization.prototype, 'page').mockResolvedValue({ - accepted: true, - }) - const buildPagePayload = rs.fn(() => ({ properties: { route: 'client' } })) - const handoff: AnalyticsOptimizationHandoff = { - ...analyticsHandoff, - initialPageEvent: 'skip', - } - - const rendered = await renderClientAsync( - - -
- - , - ) - - expect(page).not.toHaveBeenCalled() - - await rendered.rerender( - - -
- - , - ) - - expect(page).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledWith({ properties: { route: 'client' } }) - - rendered.unmount() - page.mockRestore() - }) - it('hydrates analytics handoff with a serializable initial payload', async () => { const trackCurrentPage = rs .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') @@ -257,13 +160,11 @@ describe('OptimizationAnalyticsRoot', () => { expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: expect.any(Function), - initialPageEvent: 'emit', routeKey: '/segments/a', }) const firstCall = trackCurrentPage.mock.calls[0] if (firstCall === undefined) throw new Error('Expected trackCurrentPage to be called.') const [{ buildPayload }] = firstCall - if (buildPayload === undefined) throw new Error('Expected buildPayload to be provided.') expect(buildPayload({ isInitialEmission: true })).toBe(initialPagePayload) rendered.unmount() @@ -331,7 +232,6 @@ describe('OptimizationAnalyticsRoot', () => { expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: buildPagePayload, - initialPageEvent: 'emit', routeKey: '/segments/b', }) diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx index 29381cb45..9b3871398 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx @@ -51,7 +51,6 @@ export function OptimizationAnalyticsRoot(props: OptimizationAnalyticsRootProps) const hydrationGeneration = useRef(0) const initialPropsRef = useRef(props) const runtimeRef = useRef(undefined) - const skippedInitialRouteKey = useRef(undefined) const resolvedBuildPagePayload = useMemo( () => buildPagePayload ?? (() => initialPagePayload), [buildPagePayload, initialPagePayload], @@ -75,28 +74,11 @@ export function OptimizationAnalyticsRoot(props: OptimizationAnalyticsRootProps) const generation = (hydrationGeneration.current += 1) const isCurrent = (): boolean => !disposed && hydrationGeneration.current === generation - if (skippedInitialRouteKey.current === undefined) { - skippedInitialRouteKey.current = handoff.initialPageEvent === 'skip' ? routeKey : null - } - - const initialPageEvent = skippedInitialRouteKey.current === routeKey ? 'skip' : 'emit' - - if (skippedInitialRouteKey.current !== routeKey) { - skippedInitialRouteKey.current = null - } - - void hydrateOptimizationAnalyticsHandoff( - runtime, - { - ...handoff, - initialPageEvent, - }, - { - buildPagePayload: resolvedBuildPagePayload, - isCurrent, - routeKey, - }, - ).catch((error: unknown) => { + void hydrateOptimizationAnalyticsHandoff(runtime, handoff, { + buildPagePayload: resolvedBuildPagePayload, + isCurrent, + routeKey, + }).catch((error: unknown) => { if (isCurrent()) { logger.warn('OptimizationAnalyticsRoot failed to hydrate handoff.', error) } diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx index 45277f602..6fe94a70f 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx @@ -1,5 +1,7 @@ +import { profile } from '@contentful/optimization-core/test/fixtures/profile' import ContentfulOptimization from '@contentful/optimization-web' -import { InterceptorManager } from '@contentful/optimization-web/core-sdk' +import type { OptimizationData } from '@contentful/optimization-web/api-schemas' +import { EventBuilder, InterceptorManager } from '@contentful/optimization-web/core-sdk' import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' import { logger } from '@contentful/optimization-web/logger' import { afterEach, describe, expect, it, rs } from '@rstest/core' @@ -11,10 +13,13 @@ import { useBeforeInitialPageReady } from '../context/BeforeInitialPageContext' import type { OptimizationContextValue } from '../context/OptimizationContext' import { OptimizationHydrationContext } from '../context/OptimizationHydrationContext' import { useOptimizationContext } from '../hooks/useOptimization' +import { useProfileState } from '../hooks/useOptimizationState' import { OptimizationRoot } from './OptimizationRoot' const testConfig = { spaceId: 'test-space-id', + allowedEventTypes: [], + defaults: { consent: false, persistenceConsent: false }, environment: 'main', api: { insightsBaseUrl: 'http://localhost:8000/insights/', @@ -28,7 +33,6 @@ function createContentHandoff( return { cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state: { selectedOptimizations: [] }, ...overrides, } @@ -124,6 +128,79 @@ afterEach(() => { }) describe('OptimizationRoot handoff', () => { + it('renders the SSR profile immediately while committing the prepared handoff in the background', async () => { + const data: OptimizationData = { + profile: { ...profile, traits: { name: 'Ada' } }, + changes: [], + selectedOptimizations: [], + } + const builder = new EventBuilder({ + channel: 'server', + library: { name: 'server', version: '1.0.0' }, + getConsent: () => true, + }) + const events = [builder.buildIdentify({ userId: 'customer' }), builder.buildPageView()] + const handoff = createContentHandoff({ + state: data, + profileId: data.profile.id, + replay: { routeKey: '/paired', events }, + }) + const delivery = createDeferred() + const commit = rs.spyOn(ContentfulOptimization.prototype, 'hydrateAndTrackCurrentPage') + const api = rs.spyOn(ContentfulOptimization.prototype, 'page') + const beforeInitialPage = rs.fn(async () => { + await Promise.resolve() + }) + const buildPagePayload = rs.fn(() => ({})) + function ProfileText(): ReactElement { + const current = useProfileState() + const { name } = current?.traits ?? {} + return {typeof name === 'string' ? name : 'baseline'} + } + const element = ( + + { + const sdk = window.contentfulOptimization + if (sdk === undefined) throw new Error('Expected the owned SDK.') + rs.spyOn(sdk.api.experience, 'upsertProfile').mockReturnValue(delivery.promise) + }} + > + + + + ) + expect(renderToString(element)).toContain('Ada') + const rendered = await renderClientAsync(element) + await flushMicrotasks() + expect(document.querySelector('[data-paired-profile]')?.textContent).toBe('Ada') + expect(commit).toHaveBeenCalledWith(handoff, { + routeKey: '/paired', + buildPayload: buildPagePayload, + }) + expect(api).not.toHaveBeenCalled() + expect(beforeInitialPage).not.toHaveBeenCalled() + expect(buildPagePayload).not.toHaveBeenCalled() + const sdk = window.contentfulOptimization + if (sdk === undefined) throw new Error('Expected the live SDK.') + const upsert = rs.mocked(sdk.api.experience.upsertProfile) + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert.mock.calls[0]?.[0].events.map(({ type }) => type)).toEqual(['identify', 'page']) + await rendered.rerender(element) + expect(upsert).toHaveBeenCalledTimes(1) + await act(async () => { + delivery.resolve(data) + await Promise.resolve() + }) + rendered.unmount() + }) + it('emits the initial browser page event from explicit route payload props', async () => { const trackCurrentPage = rs .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') @@ -133,7 +210,7 @@ describe('OptimizationRoot handoff', () => { const rendered = await renderClientAsync( @@ -143,7 +220,6 @@ describe('OptimizationRoot handoff', () => { expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: buildPagePayload, - initialPageEvent: 'emit', routeKey: '/products', }) @@ -160,7 +236,7 @@ describe('OptimizationRoot handoff', () => { const rendered = await renderClientAsync( @@ -170,71 +246,17 @@ describe('OptimizationRoot handoff', () => { expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: expect.any(Function), - initialPageEvent: 'emit', routeKey: '/products', }) const firstCall = trackCurrentPage.mock.calls[0] if (firstCall === undefined) throw new Error('Expected trackCurrentPage to be called.') const [{ buildPayload }] = firstCall - if (buildPayload === undefined) throw new Error('Expected buildPayload to be provided.') expect(buildPayload({ isInitialEmission: true })).toBe(initialPagePayload) rendered.unmount() trackCurrentPage.mockRestore() }) - it('marks the skipped initial route without route payload props', async () => { - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockResolvedValue({ accepted: true }) - const warn = rs.spyOn(logger, 'warn').mockImplementation(() => undefined) - - const rendered = await renderClientAsync( - -
- , - ) - - expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledWith({ - initialPageEvent: 'skip', - routeKey: '/products', - }) - expect(warn).not.toHaveBeenCalled() - - rendered.unmount() - trackCurrentPage.mockRestore() - warn.mockRestore() - }) - - it('warns and skips initial browser page emission without route payload props', async () => { - const trackCurrentPage = rs.spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - const warn = rs.spyOn(logger, 'warn').mockImplementation(() => undefined) - - const rendered = await renderClientAsync( - -
- , - ) - - expect(trackCurrentPage).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - 'React:OptimizationRoot', - expect.stringContaining('without routeKey and buildPagePayload'), - ) - - rendered.unmount() - trackCurrentPage.mockRestore() - warn.mockRestore() - }) - it('lets the root hydration prop override handoff hydration for children', async () => { let capturedHydration: unknown @@ -325,14 +347,9 @@ describe('OptimizationRoot before initial page', () => { await flushMicrotasks() expect(readiness.at(-1)).toBe(true) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', routeKey: '/initial', }) @@ -372,14 +389,9 @@ describe('OptimizationRoot before initial page', () => { expect(onError).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith(callbackError) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', routeKey: '/initial', }) @@ -421,11 +433,11 @@ describe('OptimizationRoot before initial page', () => { await rs.advanceTimersByTimeAsync(deadline) }) expect(onError).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) callback.resolve(undefined) await flushMicrotasks() - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) rendered.unmount() }, @@ -444,7 +456,7 @@ describe('OptimizationRoot before initial page', () => { const trackCurrentPage = rs .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') .mockResolvedValue({ accepted: true }) - const handoff = createContentHandoff({ initialPageEvent: 'skip' }) + const handoff = createContentHandoff() const rendered = await renderClientAsync( createBeforeInitialPageRoot({ buildPagePayload: firstBuilder, @@ -466,11 +478,6 @@ describe('OptimizationRoot before initial page', () => { expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { buildPayload: latestBuilder, - initialPageEvent: 'emit', - routeKey: '/latest', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', routeKey: '/latest', }) expect(firstBuilder).not.toHaveBeenCalled() @@ -478,7 +485,7 @@ describe('OptimizationRoot before initial page', () => { rendered.unmount() }) - it('marks the attempted route before observing route changes made during the direct page', async () => { + it('preserves the attempted route before observing route changes made during the direct page', async () => { const page = createDeferred<{ accepted: true }>() const beforeInitialPage = { run: () => undefined } const trackCurrentPage = rs @@ -492,7 +499,6 @@ describe('OptimizationRoot before initial page', () => { expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: expect.any(Function), - initialPageEvent: 'emit', routeKey: '/attempted', }) @@ -504,44 +510,15 @@ describe('OptimizationRoot before initial page', () => { page.resolve({ accepted: true }) await flushMicrotasks() - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/attempted', - }) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) await rendered.rerender( createBeforeInitialPageRoot({ beforeInitialPage, routeKey: '/after-readiness' }), ) - expect(trackCurrentPage).toHaveBeenCalledTimes(3) - expect(trackCurrentPage).toHaveBeenNthCalledWith(3, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/after-readiness', - }) - - rendered.unmount() - }) - - it('uses direct skip only after a successful same-route skip handoff', async () => { - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockResolvedValue({ accepted: true }) - const rendered = await renderClientAsync( - createBeforeInitialPageRoot({ - handoff: createContentHandoff({ initialPageEvent: 'skip' }), - beforeInitialPage: { run: () => undefined }, - }), - ) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - initialPageEvent: 'skip', - routeKey: '/initial', - }) expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/initial', + buildPayload: expect.any(Function), + routeKey: '/after-readiness', }) rendered.unmount() @@ -563,7 +540,7 @@ describe('OptimizationRoot before initial page', () => { const rendered = await renderClientAsync( createBeforeInitialPageRoot({ children: , - handoff: createContentHandoff({ initialPageEvent: 'skip' }), + handoff: createContentHandoff(), beforeInitialPage: { run: () => undefined }, }), ) @@ -574,11 +551,6 @@ describe('OptimizationRoot before initial page', () => { expect(capturedContext?.sdk).toBeInstanceOf(ContentfulOptimization) expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', routeKey: '/initial', }) @@ -597,7 +569,7 @@ describe('OptimizationRoot before initial page', () => { result: async () => await Promise.resolve({ accepted: false as const }), }, ])( - 'marks the attempted route without a same-route emitting retry after a $name direct page', + 'does not repeat the attempted route after a $name direct page', async ({ logsError, result }) => { const logError = rs.spyOn(logger, 'error').mockImplementation(() => undefined) const trackCurrentPage = rs @@ -607,14 +579,9 @@ describe('OptimizationRoot before initial page', () => { const beforeInitialPage = { run: () => undefined } const rendered = await renderClientAsync(createBeforeInitialPageRoot({ beforeInitialPage })) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', routeKey: '/initial', }) @@ -622,10 +589,9 @@ describe('OptimizationRoot before initial page', () => { createBeforeInitialPageRoot({ beforeInitialPage, routeKey: '/later' }), ) - expect(trackCurrentPage).toHaveBeenCalledTimes(3) - expect(trackCurrentPage).toHaveBeenNthCalledWith(3, { + expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', routeKey: '/later', }) expect(logError).toHaveBeenCalledTimes(logsError ? 1 : 0) @@ -690,7 +656,7 @@ describe('OptimizationRoot before initial page', () => { ) expect(run).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) rendered.unmount() }) diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx index 804c9fe7e..78e0108f1 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx @@ -1,7 +1,4 @@ -import type { - TrackCurrentPageOptions, - TrackCurrentPageSkipOptions, -} from '@contentful/optimization-web' +import type { TrackCurrentPageOptions } from '@contentful/optimization-web' import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' import { useCallback, @@ -50,10 +47,6 @@ export type OptimizationRootProps = OptimizationProviderConfigProps & OptimizationRootCommonProps & (OptimizationRootWithoutBeforeInitialPageProps | OptimizationRootWithBeforeInitialPageProps) -type DefaultOptimizationRootProps = OptimizationProviderConfigProps & - OptimizationRootCommonProps & - OptimizationRootWithoutBeforeInitialPageProps - type BeforeInitialPageOptimizationRootProps = OptimizationProviderConfigProps & OptimizationRootCommonProps & OptimizationRootWithBeforeInitialPageProps & { @@ -71,79 +64,12 @@ function InitialHandoffPageEmitter({ readonly handoff: ContentOptimizationHandoff readonly routeKey: string }): null { - useAutoPageEmitter({ - buildPayload: buildPagePayload, - enabled: true, - initialPageEvent: handoff.initialPageEvent, - routeKey, - }) - - return null -} - -type InitialHandoffPageEmitterProps = Parameters[0] - -function MissingInitialPagePayloadWarning(): null { - useEffect(() => { - logger.warn( - 'OptimizationRoot handoff requested initial page emission without routeKey and buildPagePayload; skipping initial page event.', - ) - }, []) - + useAutoPageEmitter({ buildPayload: buildPagePayload, enabled: true, handoff, routeKey }) return null } -function resolveInitialPageEmitterProps({ - buildPagePayload, - handoff, - initialPagePayload, - initialRouteKey, - routeKey, -}: { - readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] - readonly handoff?: ContentOptimizationHandoff - readonly initialPagePayload?: AutoPagePayload - readonly initialRouteKey?: string - readonly routeKey?: string -}): InitialHandoffPageEmitterProps | undefined { - if (handoff === undefined) return undefined - - const resolvedBuildPagePayload = - buildPagePayload ?? (initialPagePayload === undefined ? undefined : () => initialPagePayload) - - if (routeKey !== undefined && resolvedBuildPagePayload !== undefined) { - return { buildPagePayload: resolvedBuildPagePayload, handoff, routeKey } - } - - if ( - handoff.initialPageEvent === 'skip' && - initialRouteKey !== undefined && - resolvedBuildPagePayload === undefined - ) { - return { handoff, routeKey: initialRouteKey } - } - - return undefined -} - -function shouldWarnMissingInitialPagePayload({ - buildPagePayload, - handoff, - initialPagePayload, - routeKey, -}: { - readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] - readonly handoff?: ContentOptimizationHandoff - readonly initialPagePayload?: AutoPagePayload - readonly routeKey?: string -}): boolean { - return ( - handoff?.initialPageEvent === 'emit' && - (routeKey === undefined || (buildPagePayload === undefined && initialPagePayload === undefined)) - ) -} - function DefaultOptimizationRoot({ + beforeInitialPage: _beforeInitialPage, buildPagePayload, children, handoff, @@ -151,27 +77,23 @@ function DefaultOptimizationRoot({ liveUpdates = false, routeKey, ...providerProps -}: DefaultOptimizationRootProps): ReactElement { - const initialRouteKey = useRef(undefined) - initialRouteKey.current ??= routeKey - const initialPageEmitterProps = resolveInitialPageEmitterProps({ - buildPagePayload, - handoff, - initialPagePayload, - initialRouteKey: initialRouteKey.current, - routeKey, - }) - const shouldWarnMissingPayload = shouldWarnMissingInitialPagePayload({ - buildPagePayload, - handoff, - initialPagePayload, - routeKey, - }) - +}: OptimizationRootProps): ReactElement { + const currentRouteKey = + routeKey ?? + (typeof window === 'undefined' + ? undefined + : `${window.location.pathname}${window.location.search}`) + const buildPayload = + buildPagePayload ?? (initialPagePayload === undefined ? undefined : () => initialPagePayload) return ( - - {shouldWarnMissingPayload ? : null} - {initialPageEmitterProps ? : null} + + {handoff !== undefined && currentRouteKey !== undefined ? ( + + ) : null} {children} ) @@ -197,20 +119,18 @@ function logInitialPageError(error: unknown): void { function BeforeInitialPageSequence({ buildPagePayload, children, - handoff, beforeInitialPage, initialRouteKey, maxWaitMs, routeKey, }: PropsWithChildren<{ readonly buildPagePayload: TrackCurrentPageOptions['buildPayload'] - readonly handoff?: ContentOptimizationHandoff readonly beforeInitialPage: BeforeInitialPageOptions readonly initialRouteKey: string readonly maxWaitMs: number readonly routeKey: string }>): ReactElement { - const { error, isLive, sdk } = useOptimizationContext() + const { isLive, sdk } = useOptimizationContext() const [sequenceState, setSequenceState] = useState({ emitterRouteKey: initialRouteKey, isReady: false, @@ -218,7 +138,7 @@ function BeforeInitialPageSequence({ const buildPagePayloadRef = useRef(buildPagePayload) const currentRuntimeRef = useRef(sdk) const currentRuntimeIsLiveRef = useRef(isLive === true) - const initialHandoffRef = useRef(handoff) + const initialAttemptedRouteKeyRef = useRef(undefined) const beforeInitialPageRef = useRef(beforeInitialPage) const initialMaxWaitMsRef = useRef(maxWaitMs) const lastObservedRouteKeyRef = useRef(routeKey) @@ -263,22 +183,12 @@ function BeforeInitialPageSequence({ if (!isCurrentRuntime()) return const { current: attemptedRouteKey } = routeKeyRef - const { current: initialHandoff } = initialHandoffRef - const canSkipDirectPage = - initialHandoff !== undefined && - error === undefined && - initialHandoff.initialPageEvent === 'skip' && - attemptedRouteKey === initialRouteKey - const pageOptions: TrackCurrentPageOptions | TrackCurrentPageSkipOptions = canSkipDirectPage - ? { initialPageEvent: 'skip', routeKey: attemptedRouteKey } - : { - buildPayload: buildPagePayloadRef.current, - initialPageEvent: 'emit', - routeKey: attemptedRouteKey, - } - + initialAttemptedRouteKeyRef.current = attemptedRouteKey try { - await sequenceRuntime.trackCurrentPage(pageOptions) + await sequenceRuntime.trackCurrentPage({ + buildPayload: buildPagePayloadRef.current, + routeKey: attemptedRouteKey, + }) } catch (pageError: unknown) { logInitialPageError(pageError) } @@ -289,12 +199,13 @@ function BeforeInitialPageSequence({ lastObservedRouteKeyRef.current = currentRouteKey setSequenceState({ emitterRouteKey: attemptedRouteKey, isReady: true }) })() - }, [error, initialRouteKey, isLive, sdk]) + }, [isLive, sdk]) useAutoPageEmitter({ buildPayload: buildLatestPagePayload, - enabled: sequenceState.isReady, - initialPageEvent: 'skip', + enabled: + sequenceState.isReady && + sequenceState.emitterRouteKey !== initialAttemptedRouteKeyRef.current, routeKey: sequenceState.emitterRouteKey, }) @@ -302,6 +213,7 @@ function BeforeInitialPageSequence({ if (!sequenceState.isReady || lastObservedRouteKeyRef.current === routeKey) return lastObservedRouteKeyRef.current = routeKey + initialAttemptedRouteKeyRef.current = undefined setSequenceState({ emitterRouteKey: routeKey, isReady: true }) }, [routeKey, sequenceState.isReady]) @@ -325,10 +237,9 @@ function BeforeInitialPageOptimizationRoot({ const initialRouteKey = useRef(routeKey) return ( - + } diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx index c588ac6cc..77a1473f4 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx @@ -1,11 +1,12 @@ import ContentfulOptimization from '@contentful/optimization-web' +import { EventBuilder } from '@contentful/optimization-web/core-sdk' import { rs } from '@rstest/core' import { act, StrictMode, useEffect, useLayoutEffect, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import type { BeforeInitialPageOptions } from '../before-initial-page/beforeInitialPage' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' +import type { ContentOptimizationHandoff } from '../handoff' import { OptimizationRoot } from '../root/OptimizationRoot' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' import { @@ -156,7 +157,6 @@ describe('NextAppAutoPageTracker', () => { }) beforeEach(() => { - resetAutoPageEmitterState() setCurrentRoute('/') currentRouterState = routerState }) @@ -166,6 +166,35 @@ describe('NextAppAutoPageTracker', () => { expect(useNextAppAutoPageInputs).toBeTypeOf('function') }) + it('forwards each server handoff together with the derived current route', async () => { + const builder = new EventBuilder({ + channel: 'server', + library: { name: 'server', version: '1.0.0' }, + }) + const handoff: ContentOptimizationHandoff = { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + profileId: 'api-issued-id', + replay: { routeKey: '/one', events: [builder.buildPageView()] }, + } + const commit = rs.fn(async () => await Promise.resolve({ accepted: true as const })) + const sdk = createOptimizationSdk({ hydrateAndTrackCurrentPage: commit }) + setCurrentRoute('/one') + const rendered = await renderTracker(, sdk) + expect(commit).toHaveBeenLastCalledWith(handoff, { + routeKey: '/one', + buildPayload: expect.any(Function), + }) + const next = { ...handoff, replay: { routeKey: '/two', events: [builder.buildPageView()] } } + setCurrentRoute('/two') + await rendered.rerender() + expect(commit).toHaveBeenLastCalledWith(next, { + routeKey: '/two', + buildPayload: expect.any(Function), + }) + await rendered.unmount() + }) + it('derives lazy route inputs without emitting a page', async () => { const page = rs.fn(async () => { await Promise.resolve() @@ -291,27 +320,21 @@ describe('NextAppAutoPageTracker', () => { await Promise.resolve() }) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', routeKey: '/', }) setCurrentRoute('/page-two', new URLSearchParams('beforeInitialPage=readiness'), false) await rendered.rerender() - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) setCurrentRoute('/page-two') await rendered.rerender() - expect(trackCurrentPage).toHaveBeenCalledTimes(3) - expect(trackCurrentPage).toHaveBeenNthCalledWith(3, { + expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { buildPayload: expect.any(Function), - initialPageEvent: 'emit', routeKey: '/page-two', }) @@ -380,35 +403,6 @@ describe('NextAppAutoPageTracker', () => { await rendered.unmount() }) - it('skips only the initial route when server rendering already emitted its page event', async () => { - const page = rs.fn(async () => { - await Promise.resolve() - return undefined - }) - const sdk = createOptimizationSdk({ page }) - const rendered = await renderTracker(, sdk) - - expect(page).not.toHaveBeenCalled() - - setCurrentRoute('/products') - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - setCurrentRoute('/') - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(2) - - await rendered.unmount() - }) - it('merges static and dynamic payloads for each emission', async () => { const page = rs.fn(async () => { await Promise.resolve() diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx index 3eb34cecd..9d519b6b2 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx @@ -4,7 +4,8 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation.js' import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react' import { buildAutoPagePayload } from '../auto-page/pagePayload' import type { AutoPagePayload, AutoPagePayloadOptions } from '../auto-page/types' -import { useAutoPageEmitter, type InitialAutoPageEvent } from '../auto-page/useAutoPageEmitter' +import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' +import type { ContentOptimizationHandoff } from '../handoff' function toSearch(searchParams: { readonly toString: () => string }): string { const value = searchParams.toString() @@ -45,7 +46,7 @@ export interface NextAppAutoPageContext { } export interface NextAppAutoPageTrackerProps extends AutoPagePayloadOptions { - readonly initialPageEvent?: InitialAutoPageEvent + readonly handoff?: ContentOptimizationHandoff } interface NextAppRouteSnapshot { @@ -143,7 +144,7 @@ export function useNextAppAutoPageInputs({ } export function NextAppAutoPageTracker({ - initialPageEvent, + handoff, pagePayload, getPagePayload, }: NextAppAutoPageTrackerProps): ReactElement | null { @@ -155,7 +156,7 @@ export function NextAppAutoPageTracker({ useAutoPageEmitter({ buildPayload: buildPagePayload, enabled: true, - initialPageEvent, + handoff, routeKey, }) diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx index 8abfd2da5..79fbbf9ee 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx @@ -1,7 +1,6 @@ import { rs } from '@rstest/core' import { act, StrictMode, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' @@ -63,7 +62,6 @@ describe('NextPagesAutoPageTracker', () => { }) beforeEach(() => { - resetAutoPageEmitterState() routerState.asPath = '/' routerState.isReady = true routerState.pathname = '/' @@ -139,37 +137,6 @@ describe('NextPagesAutoPageTracker', () => { await rendered.unmount() }) - it('skips only the initial route when server rendering already emitted its page event', async () => { - const page = rs.fn(async () => { - await Promise.resolve() - return undefined - }) - const sdk = createOptimizationSdk({ page }) - const rendered = await renderTracker(, sdk) - - expect(page).not.toHaveBeenCalled() - - routerState.asPath = '/products' - routerState.pathname = '/products' - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - routerState.asPath = '/' - routerState.pathname = '/' - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(2) - - await rendered.unmount() - }) - it('waits until the router is ready', async () => { const page = rs.fn(async () => { await Promise.resolve() diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx index e568e7141..90c9b0333 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx @@ -4,7 +4,8 @@ import { useRouter, type NextRouter } from 'next/router.js' import { useCallback, useMemo, type ReactElement } from 'react' import { buildAutoPagePayload } from '../auto-page/pagePayload' import type { AutoPagePayload, AutoPagePayloadOptions } from '../auto-page/types' -import { useAutoPageEmitter, type InitialAutoPageEvent } from '../auto-page/useAutoPageEmitter' +import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' +import type { ContentOptimizationHandoff } from '../handoff' function splitAsPath(asPath: string): { path: string; search: string } { const queryIndex = asPath.indexOf('?') @@ -47,11 +48,11 @@ export interface NextPagesAutoPageContext { } export interface NextPagesAutoPageTrackerProps extends AutoPagePayloadOptions { - readonly initialPageEvent?: InitialAutoPageEvent + readonly handoff?: ContentOptimizationHandoff } export function NextPagesAutoPageTracker({ - initialPageEvent, + handoff, pagePayload, getPagePayload, }: NextPagesAutoPageTrackerProps): ReactElement | null { @@ -91,7 +92,7 @@ export function NextPagesAutoPageTracker({ [asPath, getPagePayload, pagePayload, pathname, query, routeKey, router, routerPayload], ) - useAutoPageEmitter({ enabled: isReady, initialPageEvent, routeKey, buildPayload }) + useAutoPageEmitter({ enabled: isReady, handoff, routeKey, buildPayload }) return null } diff --git a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx index 775210800..b39ff8333 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx @@ -1,7 +1,6 @@ import { rs } from '@rstest/core' import { act, StrictMode, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' @@ -65,7 +64,6 @@ describe('ReactRouterAutoPageTracker', () => { }) beforeEach(() => { - resetAutoPageEmitterState() locationState.hash = '' locationState.key = 'default' locationState.pathname = '/' diff --git a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx index e5cfa4426..d069e22aa 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx @@ -9,7 +9,6 @@ import { } from '@tanstack/react-router' import { act, StrictMode, type ReactElement, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' @@ -102,10 +101,6 @@ async function navigateTo(router: TestRouter, path: string): Promise { } describe('TanStackRouterAutoPageTracker', () => { - beforeEach(() => { - resetAutoPageEmitterState() - }) - it('is exported from the router subpath module', () => { expect(TanStackRouterAutoPageTracker).toBeTypeOf('function') }) diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index 24a94d079..57e8dc306 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -209,11 +209,6 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): (async (options) => { const { routeKey } = options - if (options.initialPageEvent === 'skip') { - acceptedRouteKey = routeKey - return { accepted: true } - } - if (!hasConsent('page') || acceptedRouteKey === routeKey || inFlightRouteKey === routeKey) { return { accepted: false } } @@ -297,6 +292,13 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): return undefined }, trackCurrentPage, + hydrateAndTrackCurrentPage: async ( + ...[_handoff, options]: Parameters + ) => + await trackCurrentPage({ + ...options, + buildPayload: options.buildPayload ?? (() => ({})), + }), trackView: async () => { await Promise.resolve() return { accepted: true } diff --git a/packages/web/preview-panel/AGENTS.md b/packages/web/preview-panel/AGENTS.md index 57bdca7ec..16af28931 100644 --- a/packages/web/preview-panel/AGENTS.md +++ b/packages/web/preview-panel/AGENTS.md @@ -20,4 +20,7 @@ Owns the Web preview panel micro-frontend built with Lit and integrated with - Run `typecheck` and `build`. - Handle bundle-size failures under the root `Bundle size` policy. +- For approved increases to exceeded budgets or requested normalization, set each authorized budget + to `ceil((measuredGzipBytes + 100) / 100) * 100` under the root + [Bundle size](../../../AGENTS.md#bundle-size) measurement and approval rules. - Validate the package-local `dev` flow when changing panel flows it exercises. diff --git a/packages/web/web-sdk/AGENTS.md b/packages/web/web-sdk/AGENTS.md index c62b9fc2e..c406261a5 100644 --- a/packages/web/web-sdk/AGENTS.md +++ b/packages/web/web-sdk/AGENTS.md @@ -22,4 +22,7 @@ Owns browser-specific SDK behavior, Web runtime concerns, and entry interaction - Run `typecheck`, `test:unit`, and `build`. - Handle bundle-size failures under the root `Bundle size` policy. +- For approved increases to exceeded budgets or requested normalization, set each authorized budget + to `ceil((measuredGzipBytes + 100) / 100) * 100` under the root + [Bundle size](../../../AGENTS.md#bundle-size) measurement and approval rules. - Validate the package-local `dev` flow when changing flows it demonstrates. From 4b4e05e5ada793f1cd4cc678429c17a04e97e305 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Wed, 7 Oct 2026 13:14:57 +0200 Subject: [PATCH 06/29] feat(nextjs)!: prepare paired replay handoffs --- .../web/frameworks/nextjs-sdk/package.json | 34 +- .../nextjs-sdk/src/app-router-client.test.tsx | 17 +- .../nextjs-sdk/src/app-router-client.ts | 4 +- .../src/app-router-request-handoff.ts | 72 --- .../src/app-router-request-runtime.tsx | 89 ++-- .../nextjs-sdk/src/app-router-server.test.tsx | 491 +++++------------- .../nextjs-sdk/src/app-router-server.tsx | 34 +- .../nextjs-sdk/src/bound-component-types.ts | 34 +- .../web/frameworks/nextjs-sdk/src/client.ts | 1 - .../web/frameworks/nextjs-sdk/src/cookies.ts | 73 ++- .../frameworks/nextjs-sdk/src/edge.test.ts | 119 +++-- .../web/frameworks/nextjs-sdk/src/edge.ts | 72 +-- .../web/frameworks/nextjs-sdk/src/handoff.ts | 9 +- .../src/pages-router-server.test.ts | 89 ++-- .../nextjs-sdk/src/pages-router-server.ts | 35 +- .../nextjs-sdk/src/pages-router.test.tsx | 19 +- .../frameworks/nextjs-sdk/src/pages-router.ts | 4 +- .../nextjs-sdk/src/request-context.ts | 18 +- .../nextjs-sdk/src/request-handler.test.ts | 465 +++++------------ .../nextjs-sdk/src/request-handler.ts | 107 ++-- .../nextjs-sdk/src/runtime-types.test.ts | 9 +- .../web/frameworks/nextjs-sdk/src/server.tsx | 89 ++-- 22 files changed, 667 insertions(+), 1217 deletions(-) diff --git a/packages/web/frameworks/nextjs-sdk/package.json b/packages/web/frameworks/nextjs-sdk/package.json index 21113dd8b..649abc07f 100644 --- a/packages/web/frameworks/nextjs-sdk/package.json +++ b/packages/web/frameworks/nextjs-sdk/package.json @@ -129,26 +129,26 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "app-router.cjs": 1700, - "app-router.mjs": 1600, - "app-router-server.cjs": 4900, - "app-router-server.mjs": 6700, - "cache-middleware.cjs": 1900, + "app-router.cjs": 1800, + "app-router.mjs": 1400, + "app-router-server.cjs": 5800, + "app-router-server.mjs": 8000, + "cache-middleware.cjs": 2000, "cache-middleware.mjs": 1700, "client.cjs": 1100, - "client.mjs": 600, - "edge.cjs": 3100, - "edge.mjs": 3100, + "client.mjs": 400, + "edge.cjs": 3200, + "edge.mjs": 3200, "pages-router.cjs": 1800, - "pages-router.mjs": 1600, - "pages-router/server.cjs": 3900, - "pages-router/server.mjs": 5600, - "server.cjs": 2700, - "server.mjs": 3900, - "request-handler.cjs": 2900, - "request-handler.mjs": 5100, - "tracking-attributes.cjs": 700, - "tracking-attributes.mjs": 400 + "pages-router.mjs": 1400, + "pages-router/server.cjs": 4000, + "pages-router/server.mjs": 5500, + "server.cjs": 2800, + "server.mjs": 4000, + "request-handler.cjs": 2100, + "request-handler.mjs": 2800, + "tracking-attributes.cjs": 800, + "tracking-attributes.mjs": 300 } } }, diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx index 9ccbaa374..e5b1037cf 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx @@ -57,6 +57,13 @@ describe('Next.js App Router client components', () => { ...testConfig, consent: { clientDefaults: { consent: false, persistenceConsent: false } }, contentful, + cookie: { + path: '/products', + sameSite: 'strict', + secure: true, + domain: 'example.test', + expires: 1, + }, liveUpdates: true, }) const handoff = components.createHandoffFromSelections({ @@ -65,7 +72,6 @@ describe('Next.js App Router client components', () => { { baselineEntry: createEntry('4ib0hsHWoSOnCVdDkizE8d'), entryId: '4ib0hsHWoSOnCVdDkizE8d' }, ], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -92,6 +98,13 @@ describe('Next.js App Router client components', () => { expect(components).not.toHaveProperty('createRequestHandoff') expect(components).not.toHaveProperty('request') expect(element.props).toMatchObject({ + cookie: { + path: '/products', + sameSite: 'strict', + secure: true, + domain: 'example.test', + expires: 1, + }, api: testConfig.api, children: 'Bound content', spaceId: testConfig.spaceId, @@ -138,7 +151,6 @@ describe('Next.js App Router client components', () => { cache: { scope: 'static' }, entries: [{ baselineEntry, entryId: '4ib0hsHWoSOnCVdDkizE8d' }], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -166,7 +178,6 @@ describe('Next.js App Router client components', () => { const analyticsHandoff = components.createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }) const root = components.OptimizationRoot({ diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts index 1947d3915..f198aaa72 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts @@ -157,10 +157,11 @@ export function bindNextjsAppRouterClientOptimization( handoff, hydration, prefetchManagedEntries, + routeKey, }: BoundNextjsOptimizationProviderProps): ReactElement | null { return createElement( ReactWebOptimizationProvider, - { ...providerConfig, handoff, hydration, prefetchManagedEntries }, + { ...providerConfig, handoff, hydration, prefetchManagedEntries, routeKey }, createElement( ReactWebLiveUpdatesProvider, { globalLiveUpdates: config.liveUpdates }, @@ -210,6 +211,7 @@ function toClientRootConfig( return { ...clientConfig, defaults: consent?.clientDefaults, + cookie: config.cookie, } } diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts b/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts index e4cdc1322..0f6d79643 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts @@ -3,29 +3,11 @@ import type { PrivateRequestOptimizationCacheMetadata, StatefulDefaults, } from '@contentful/optimization-react-web/core-sdk' -import { - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - parseNextjsOptimizationRequestContext, -} from './request-context' import type { CoreStatelessRequestConsent } from './server' const REQUEST_HANDOFF_CACHE_SCOPE_ERROR = 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.' -export interface NextjsForwardedServerData { - readonly consent: CoreStatelessRequestConsent - readonly pageAccepted: boolean - readonly profileId?: string -} - -interface ForwardedProfileOptionsInput { - readonly experienceOptions?: { - readonly ip?: string - readonly locale?: string - } - readonly locale?: string -} - export function assertRequestHandoffCacheMetadata( cache: OptimizationCacheMetadata, ): asserts cache is PrivateRequestOptimizationCacheMetadata { @@ -33,21 +15,6 @@ export function assertRequestHandoffCacheMetadata( throw new TypeError(REQUEST_HANDOFF_CACHE_SCOPE_ERROR) } -export function readNextjsForwardedServerData( - headers: Headers, - trustedRequestHandoff: true | undefined, -): NextjsForwardedServerData | undefined { - if (trustedRequestHandoff === undefined) return undefined - - const encodedValue = headers.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) - - if (encodedValue === null) return undefined - - const value = parseNextjsOptimizationRequestContext(encodedValue) - - return isNextjsForwardedServerData(value) ? value : undefined -} - export function toHandoffDefaults(consent: CoreStatelessRequestConsent): StatefulDefaults { if (typeof consent === 'boolean') { return { consent, persistenceConsent: consent } @@ -58,42 +25,3 @@ export function toHandoffDefaults(consent: CoreStatelessRequestConsent): Statefu persistenceConsent: consent.persistence ?? false, } } - -export function toForwardedProfileOptions( - options: ForwardedProfileOptionsInput, - configLocale: string | undefined, -): { readonly ip?: string; readonly locale?: string } { - const locale = options.locale ?? configLocale ?? options.experienceOptions?.locale - - return { - ...(options.experienceOptions?.ip === undefined ? {} : { ip: options.experienceOptions.ip }), - ...(locale === undefined ? {} : { locale }), - } -} - -function isNextjsForwardedServerData(value: unknown): value is NextjsForwardedServerData { - return ( - isRecord(value) && - isCoreStatelessRequestConsent(value.consent) && - typeof value.pageAccepted === 'boolean' && - (value.profileId === undefined || isProfileId(value.profileId)) - ) -} - -function isCoreStatelessRequestConsent(value: unknown): value is CoreStatelessRequestConsent { - if (typeof value === 'boolean') return true - if (!isRecord(value)) return false - - return ( - (value.events === undefined || typeof value.events === 'boolean') && - (value.persistence === undefined || typeof value.persistence === 'boolean') - ) -} - -function isProfileId(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx index 83395fa35..0ef5c4084 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx @@ -1,4 +1,3 @@ -import { createRequestHandoffFromData } from '@contentful/optimization-node' import type { OptimizationCacheMetadata, PrivateRequestOptimizationCacheMetadata, @@ -6,12 +5,7 @@ import type { import { NextAppAutoPageTracker } from '@contentful/optimization-react-web/router/next-app' import { cookies, headers } from 'next/headers' import { cache, createElement, type ReactElement } from 'react' -import { - assertRequestHandoffCacheMetadata, - readNextjsForwardedServerData, - toForwardedProfileOptions, - toHandoffDefaults, -} from './app-router-request-handoff' +import { assertRequestHandoffCacheMetadata, toHandoffDefaults } from './app-router-request-handoff' import type { BoundNextjsOptimizationProviderProps, BoundNextjsOptimizationRootProps, @@ -24,13 +18,15 @@ import type { NextjsOptimizationServerConsent, NextjsOptimizationServerConsentResolver, } from './bound-component-types' -import { - addBrowserHandoffMetadata, - type BrowserOptimizationHandoff, - type ContentOptimizationHandoff, - type ContentOptimizationHydrationMode, +import type { + BrowserOptimizationHandoff, + ContentOptimizationHandoff, + ContentOptimizationHydrationMode, } from './handoff' -import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' +import { + NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, + normalizeNextjsAppRequestUrl, +} from './request-context' import { createNextjsRequestHandoff, type ContentfulOptimization, @@ -49,7 +45,6 @@ export type AppRouterCreateRequestHandoffOptions = Omit< readonly hydration: ContentOptimizationHydrationMode readonly locale?: string readonly request: NextjsRequestLike - readonly trustedRequestHandoff?: true } interface BindNextjsAppRouterRequestRuntimeOptions { @@ -97,34 +92,6 @@ export function bindNextjsAppRouterRequestRuntime({ } assertRequestHandoffCacheMetadata(cacheMetadata) - const forwardedServerData = readNextjsForwardedServerData( - options.request.headers, - options.trustedRequestHandoff, - ) - if (forwardedServerData !== undefined) { - const data = - forwardedServerData.profileId === undefined - ? undefined - : await sdk.api.experience.getProfile( - forwardedServerData.profileId, - toForwardedProfileOptions(options, config.locale), - ) - const handoff = addBrowserHandoffMetadata( - createRequestHandoffFromData({ - cache: cacheMetadata, - data, - entries: options.entries, - }), - { - hydration: options.hydration, - initialPageEvent: forwardedServerData.pageAccepted ? 'skip' : 'emit', - }, - ) - rememberRequestHandoff(handoff, toHandoffDefaults(forwardedServerData.consent)) - - return handoff - } - const consent = await resolveServerConsent(config.consent?.server, { cookies: options.request.cookies ?? EMPTY_COOKIE_READER, headers: options.request.headers, @@ -134,7 +101,7 @@ export function bindNextjsAppRouterRequestRuntime({ cache: cacheMetadata, consent, locale: options.locale ?? config.locale, - request: options.request, + request: { ...options.request, url: normalizeNextjsAppRequestUrl(options.request.url) }, }) rememberRequestHandoff(handoff, toHandoffDefaults(consent)) @@ -153,20 +120,34 @@ export function bindNextjsAppRouterRequestRuntime({ ) } - const url = new URL(requestUrl) + const visibleRequestUrl = normalizeNextjsAppRequestUrl(requestUrl) + const url = new URL(visibleRequestUrl) const routeKey = `${url.pathname}${url.search}` - const pagePayload = { - properties: { path: url.pathname, search: url.search, url: requestUrl }, + const context = { + requestUrl: visibleRequestUrl, + routeKey, + cookies: cookieStore, + headers: requestHeaders, } + const requestConfig = config.request ?? {} + const { initialEvents: configuredEvents, pagePayload: configuredPage } = requestConfig + const initialEvents = + typeof configuredEvents === 'function' ? await configuredEvents(context) : configuredEvents + const pagePayload = + typeof configuredPage === 'function' + ? await configuredPage(context) + : (configuredPage ?? { + properties: { path: url.pathname, search: url.search, url: visibleRequestUrl }, + }) const hydration = - typeof config.request?.hydration === 'function' - ? config.request.hydration({ requestUrl, routeKey }) - : (config.request?.hydration ?? 'preserve-server') + typeof requestConfig.hydration === 'function' + ? requestConfig.hydration(context) + : (requestConfig.hydration ?? 'preserve-server') const handoff = await createRequestHandoff({ hydration, pagePayload, - request: { cookies: cookieStore, headers: requestHeaders, url: requestUrl }, - trustedRequestHandoff: config.request?.trustedRequestHandoff, + initialEvents, + request: { cookies: cookieStore, headers: requestHeaders, url: visibleRequestUrl }, }) return { handoff, hydration, pagePayload, routeKey } @@ -199,7 +180,7 @@ export function bindNextjsAppRouterRequestRuntime({ ): Promise { const { prefetchManagedEntries, ...providerProps } = props const requestInputs = getRequestRenderInputs() - const [{ hydration }, handoff] = await Promise.all([ + const [{ hydration, routeKey }, handoff] = await Promise.all([ requestInputs, resolveHandoffEntries( requestInputs.then((inputs) => inputs.handoff), @@ -207,7 +188,7 @@ export function bindNextjsAppRouterRequestRuntime({ ), ]) - return await OptimizationProvider({ ...providerProps, handoff, hydration }) + return await OptimizationProvider({ ...providerProps, handoff, hydration, routeKey }) } const RequestOptimizedEntry: NextjsBoundOptimizedEntryComponent> = async ( @@ -221,7 +202,7 @@ export function bindNextjsAppRouterRequestRuntime({ return createElement(NextAppAutoPageTracker, { ...props, - initialPageEvent: handoff.initialPageEvent, + handoff, }) } diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx index 4367bdc1e..d3660495e 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx @@ -1,7 +1,8 @@ import ContentfulOptimizationRuntime from '@contentful/optimization-node' +import type { ExperienceApiClient } from '@contentful/optimization-node/api-client' import type { MergeTagEntry } from '@contentful/optimization-node/api-schemas' -import type { CoreStatelessRequest } from '@contentful/optimization-node/core-sdk' import { useConsentState, useSelectedOptimizationsState } from '@contentful/optimization-react-web' +import { NextRequest } from 'next/server' import { PassThrough } from 'node:stream' import type { ReactElement } from 'react' import * as React from 'react' @@ -12,15 +13,10 @@ import type { createPublicPermutationCacheMetadata as createPublicPermutationCacheMetadataFactory, createPublicPermutationHandoff as createPublicPermutationHandoffFactory, } from './app-router-server' -import { - NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext, -} from './request-context' +import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' import type { OptimizationData, ServerTrackingBaselineEntry } from './server' type CacheableFunction = (...args: never[]) => unknown -type FetchMethod = (input: string | Request, init?: RequestInit) => Promise let bindNextjsAppRouterServerOptimization: typeof bindNextjsAppRouterServerOptimizationFactory let createStandaloneHandoffFromSelections: typeof createHandoffFromSelectionsFactory @@ -69,6 +65,7 @@ const sdkConfig = { spaceId: 'test-space-id', environment: 'main', locale: 'en-US', + consent: { server: true }, } const baselineEntry = createEntry('4ib0hsHWoSOnCVdDkizE8d') @@ -121,13 +118,6 @@ const optimizationData: OptimizationData = { }, } -function setForwardedServerData(headers: Headers, value: unknown): void { - headers.set( - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext(value), - ) -} - afterEach(() => { reactCacheTestGeneration += 1 rs.restoreAllMocks() @@ -247,24 +237,24 @@ function createMergeTagEntry(id: string, selector: string): MergeTagEntry { } } -function mockRequestPage(result: Awaited>): { +function mockRequestPreview(data: OptimizationData = optimizationData): { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly preview: ReturnType> } { const originalForRequest = ContentfulOptimizationRuntime.prototype.forRequest - const page = rs.fn(async () => await Promise.resolve(result)) + const preview = rs.fn( + async () => await Promise.resolve(data), + ) const forRequest = rs.spyOn(ContentfulOptimizationRuntime.prototype, 'forRequest') - forRequest.mockImplementation(function mockForRequest( this: ContentfulOptimizationRuntime, options, ) { const requestOptimization = originalForRequest.call(this, options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) + rs.spyOn(this.api.experience, 'upsertProfile').mockImplementation(preview) return requestOptimization }) - - return { forRequest, page } + return { forRequest, preview } } function createRequest(): { @@ -289,27 +279,6 @@ function setCurrentNextRequest( return currentNextRequest } -function mockProfileFetch( - data: OptimizationData = optimizationData, -): ReturnType> { - return rs.fn( - async () => - await Promise.resolve( - new Response( - JSON.stringify({ - data: { - changes: data.changes, - experiences: data.selectedOptimizations, - profile: data.profile, - }, - error: null, - message: 'ok', - }), - ), - ), - ) -} - async function renderToHtml(element: ReactElement): Promise { return await new Promise((resolve, reject) => { let html = '' @@ -346,12 +315,11 @@ function getElementProps(element: ReactElement): Record { return Object.fromEntries(Object.entries(element.props)) } -describe('Next.js App Router v2 binding', () => { +describe('Next.js App Router binding', () => { it('exposes handoff, analytics, tracking, and resolution helpers', () => { const optimization = bindNextjsAppRouterServerOptimization(sdkConfig) expect(appRouterServerExports.bindNextjsAppRouterServerOptimization).toBeTypeOf('function') - expect(appRouterServerExports).not.toHaveProperty('createNextjsAppRouterOptimization') expect(optimization.OptimizationRoot).toBeTypeOf('function') expect(optimization.OptimizationAnalyticsRoot).toBeTypeOf('function') expect(optimization.OptimizedEntry).toBeTypeOf('function') @@ -373,7 +341,7 @@ describe('Next.js App Router v2 binding', () => { it('replaces only the request root with an injected serializable client root', async () => { setCurrentNextRequest('https://example.test/page-two?beforeInitialPage=readiness') - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const ClientRequestOptimizationRoot = (_props: { readonly children?: React.ReactNode }): ReactElement => React.createElement(React.Fragment) @@ -388,10 +356,9 @@ describe('Next.js App Router v2 binding', () => { expect(requestRoot.type).toBe(ClientRequestOptimizationRoot) expect(requestProps).toMatchObject({ children: 'Request content', - defaults: { consent: false, persistenceConsent: false }, + defaults: { consent: true, persistenceConsent: true }, handoff: { hydration: 'preserve-server', - initialPageEvent: 'skip', }, hydration: 'preserve-server', }) @@ -406,12 +373,12 @@ describe('Next.js App Router v2 binding', () => { it('waits for the shared request handoff when OptimizedEntry starts before the root', async () => { setCurrentNextRequest() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - let resolvePage: ((result: { accepted: true; data: OptimizationData }) => void) | undefined - const delayedPage = new Promise<{ accepted: true; data: OptimizationData }>((resolve) => { + const { preview } = mockRequestPreview() + let resolvePage: ((result: OptimizationData) => void) | undefined + const delayedPage = new Promise((resolve) => { resolvePage = resolve }) - page.mockImplementationOnce(async () => await delayedPage) + preview.mockImplementationOnce(async () => await delayedPage) const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) const entryPromise = request.OptimizedEntry({ @@ -422,12 +389,12 @@ describe('Next.js App Router v2 binding', () => { await Promise.resolve() await Promise.resolve() - resolvePage?.({ accepted: true, data: optimizationData }) + resolvePage?.(optimizationData) const [entry, root] = await Promise.all([entryPromise, rootPromise]) const html = await renderToHtml(entry) - expect(page).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledTimes(1) expect(getElementProps(root).handoff).toBeDefined() expect(html).toContain(`data-ctfl-entry-id="${variantEntry.sys.id}"`) expect(html).toContain(variantEntry.sys.id) @@ -438,13 +405,10 @@ describe('Next.js App Router v2 binding', () => { const experienceStarted = Promise.withResolvers() const cdaStarted = Promise.withResolvers() const cdaFinished = Promise.withResolvers() - const experienceRelease = Promise.withResolvers<{ - accepted: true - data: OptimizationData - }>() + const experienceRelease = Promise.withResolvers() const cdaRelease = Promise.withResolvers() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockImplementationOnce(async () => { + const { preview } = mockRequestPreview() + preview.mockImplementationOnce(async () => { experienceStarted.resolve(undefined) return await experienceRelease.promise }) @@ -472,7 +436,7 @@ describe('Next.js App Router v2 binding', () => { void entryPromise.then(settled) await Promise.all([experienceStarted.promise, cdaStarted.promise]) - expect(page).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledTimes(1) expect(getEntry).toHaveBeenCalledTimes(1) cdaRelease.resolve(optimizedEntry) @@ -481,7 +445,7 @@ describe('Next.js App Router v2 binding', () => { expect(resolveOptimizedEntry).not.toHaveBeenCalled() expect(settled).not.toHaveBeenCalled() - experienceRelease.resolve({ accepted: true, data: optimizationData }) + experienceRelease.resolve(optimizationData) const html = await renderToHtml(await entryPromise) expect(resolveOptimizedEntry).toHaveBeenCalledTimes(1) @@ -495,13 +459,10 @@ describe('Next.js App Router v2 binding', () => { const experienceStarted = Promise.withResolvers() const experienceFinished = Promise.withResolvers() const cdaStarted = Promise.withResolvers() - const experienceRelease = Promise.withResolvers<{ - accepted: true - data: OptimizationData - }>() + const experienceRelease = Promise.withResolvers() const cdaRelease = Promise.withResolvers() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockImplementationOnce(async () => { + const { preview } = mockRequestPreview() + preview.mockImplementationOnce(async () => { experienceStarted.resolve(undefined) const result = await experienceRelease.promise experienceFinished.resolve(undefined) @@ -526,10 +487,10 @@ describe('Next.js App Router v2 binding', () => { void componentPromise.then(settled) await Promise.all([experienceStarted.promise, cdaStarted.promise]) - expect(page).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledTimes(1) expect(getEntry).toHaveBeenCalledTimes(1) - experienceRelease.resolve({ accepted: true, data: optimizationData }) + experienceRelease.resolve(optimizationData) await experienceFinished.promise expect(settled).not.toHaveBeenCalled() @@ -549,53 +510,12 @@ describe('Next.js App Router v2 binding', () => { }, ) - it.each(['managed prefetch', 'OptimizedEntry'] as const)( - 'surfaces request initialization failure before %s CDA failure', - async (component) => { - setCurrentNextRequest() - const requestError = new Error('Request initialization failed') - const cdaStarted = Promise.withResolvers() - const requestRelease = Promise.withResolvers<{ - accepted: true - data: OptimizationData - }>() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockImplementationOnce(async () => await requestRelease.promise) - const getEntry = rs.fn(async () => { - cdaStarted.resolve(undefined) - return await Promise.reject(new Error('CDA failed')) - }) - const getEntries = rs.fn(async () => await Promise.resolve(createEntryCollection([]))) - const { request } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - contentful: { cache: false, client: { getEntry, getEntries } }, - }) - const settled = rs.fn() - const result = ( - component === 'managed prefetch' - ? request.OptimizationRoot({ - children: null, - prefetchManagedEntries: [baselineEntry.sys.id], - }) - : request.OptimizedEntry({ children: null, entryId: baselineEntry.sys.id }) - ).catch((error: unknown) => error) - void result.then(settled) - - await cdaStarted.promise - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - - requestRelease.reject(requestError) - expect(await result).toBe(requestError) - }, - ) - it.each(['managed prefetch', 'OptimizedEntry'] as const)( 'surfaces %s CDA failure after successful request initialization', async (component) => { setCurrentNextRequest() const cdaError = new Error('CDA failed') - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const getEntry = rs.fn(async () => await Promise.reject(cdaError)) const getEntries = rs.fn(async () => await Promise.resolve(createEntryCollection([]))) const { request } = bindNextjsAppRouterServerOptimization({ @@ -617,7 +537,7 @@ describe('Next.js App Router v2 binding', () => { it('initializes all request wrappers from one cached resource', async () => { setCurrentNextRequest() - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) + const { forRequest, preview } = mockRequestPreview() const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) const [root, provider, entry, tracker] = await Promise.all([ @@ -631,11 +551,11 @@ describe('Next.js App Router v2 binding', () => { ]) expect(forRequest).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledTimes(1) expect(getElementProps(root).handoff).toBe( provider === null ? undefined : getElementProps(provider).handoff, ) - expect(getElementProps(tracker).initialPageEvent).toBe('skip') + expect(getElementProps(tracker).handoff).toBe(getElementProps(root).handoff) expect(await renderToHtml(entry)).toContain(variantEntry.sys.id) expect(readNextCookies).toHaveBeenCalledTimes(1) expect(readNextHeaders).toHaveBeenCalledTimes(1) @@ -653,7 +573,7 @@ describe('Next.js App Router v2 binding', () => { ] as const)('uses %s request hydration', async (_label, hydration, expectedHydration) => { const url = 'https://example.test/products?tab=featured' setCurrentNextRequest(url) - mockRequestPage({ accepted: true }) + mockRequestPreview() const { request } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, request: hydration === undefined ? undefined : { hydration }, @@ -678,25 +598,6 @@ describe('Next.js App Router v2 binding', () => { ) }) - it('uses trusted forwarded handoff state and preserves page-event ownership only when opted in', async () => { - const forwardedRequest = setCurrentNextRequest() - setForwardedServerData(forwardedRequest.headers, { - consent: true, - pageAccepted: true, - }) - const { forRequest, page } = mockRequestPage({ accepted: false }) - const { request } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - request: { trustedRequestHandoff: true }, - }) - - const tracker = await request.NextAppAutoPageTracker({}) - - expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - expect(getElementProps(tracker).initialPageEvent).toBe('skip') - }) - it('isolates request URL, profile, handoff, and selected-entry state across RSC requests', async () => { const secondProfile = { ...optimizationData.profile, @@ -708,9 +609,9 @@ describe('Next.js App Router v2 binding', () => { profile: secondProfile, selectedOptimizations: [], } - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockResolvedValueOnce({ accepted: true, data: optimizationData }) - page.mockResolvedValueOnce({ accepted: true, data: secondData }) + const { forRequest, preview } = mockRequestPreview() + preview.mockResolvedValueOnce(optimizationData) + preview.mockResolvedValueOnce(secondData) const hydration = rs.fn(() => 'preserve-server' as const) const { request } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, @@ -733,10 +634,20 @@ describe('Next.js App Router v2 binding', () => { }) expect(forRequest).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenCalledTimes(2) expect(hydration.mock.calls).toEqual([ - [{ requestUrl: 'https://example.test/first?segment=a', routeKey: '/first?segment=a' }], - [{ requestUrl: 'https://example.test/second?segment=b', routeKey: '/second?segment=b' }], + [ + expect.objectContaining({ + requestUrl: 'https://example.test/first?segment=a', + routeKey: '/first?segment=a', + }), + ], + [ + expect.objectContaining({ + requestUrl: 'https://example.test/second?segment=b', + routeKey: '/second?segment=b', + }), + ], ]) expect(getElementProps(firstRoot).handoff).not.toBe(getElementProps(secondRoot).handoff) expect(getElementProps(firstRoot)).toMatchObject({ @@ -751,7 +662,7 @@ describe('Next.js App Router v2 binding', () => { }) it('keeps top-level static, public, analytics, and manual paths free of Next.js request reads', async () => { - const { page } = mockRequestPage({ accepted: true }) + const { preview } = mockRequestPreview() const { OptimizationAnalyticsRoot, OptimizationRoot, @@ -762,18 +673,15 @@ describe('Next.js App Router v2 binding', () => { const staticHandoff = createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) createPublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], }) const publicHandoff = createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], }) @@ -786,7 +694,7 @@ describe('Next.js App Router v2 binding', () => { request: createRequest(), }) - expect(page).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledTimes(1) expect(readNextCookies).not.toHaveBeenCalled() expect(readNextHeaders).not.toHaveBeenCalled() }) @@ -809,7 +717,6 @@ describe('Next.js App Router v2 binding', () => { cache: { scope: 'static' }, entries: [{ baselineEntry: variantEntry, entryId: variantEntry.sys.id }], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -890,214 +797,94 @@ describe('Next.js App Router v2 binding', () => { expect(element?.props).not.toHaveProperty('prefetchManagedEntries') }) - it.each([ - ['accepted with data', { accepted: true, data: optimizationData }, 'skip'], - ['accepted without data', { accepted: true }, 'skip'], - ['blocked', { accepted: false }, 'emit'], - ['pre-consent accepted', { accepted: true, data: optimizationData }, 'skip'], - ] as const)( - 'creates request handoff with initialPageEvent from page acceptance: %s', - async (_label, pageResult, expectedInitialPageEvent) => { - const { forRequest, page } = mockRequestPage(pageResult) - const serverConsent = _label !== 'pre-consent accepted' - const { createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - consent: { server: serverConsent }, - }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request: createRequest(), - }) - - expect(page).toHaveBeenCalledWith({ properties: { route: '/products' } }) - expect(forRequest).toHaveBeenCalledWith( - expect.objectContaining({ - consent: serverConsent, - eventContext: expect.objectContaining({ - page: expect.objectContaining({ - path: '/products', - search: '?tab=featured', - }), - userAgent: 'app-router-agent', - }), - profile: { id: 'incoming-id' }, - }), - ) - expect(handoff.initialPageEvent).toBe(expectedInitialPageEvent) - expect(handoff.cache).toEqual({ scope: 'private-request' }) - }, - ) - - it.each([ - ['accepted with data', true], - ['not accepted with data', false], - ] as const)( - 'creates request handoff from trusted forwarded server data while browser owns page payload: %s', - async (_label, pageAccepted) => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const getProfile = mockProfileFetch() - const { OptimizationRoot, createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - fetchOptions: { fetchMethod: getProfile }, - }) - - setForwardedServerData(request.headers, { - consent: { events: true, persistence: false }, - pageAccepted, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, - trustedRequestHandoff: true, - }) - const element = await OptimizationRoot({ children: 'Server content', handoff }) - - expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - expect(getProfile).toHaveBeenCalledTimes(1) - const profileUrl = getProfile.mock.calls[0]?.[0] - if (typeof profileUrl !== 'string') throw new Error('Expected getProfile URL string.') - expect(profileUrl).toContain('/profiles/f0837d7dc6344c36a3a0a06c4cde754b') - expect(profileUrl).toContain('locale=en-US') - expect(handoff.initialPageEvent).toBe(pageAccepted ? 'skip' : 'emit') - expect(handoff.cache).toEqual({ scope: 'private-request' }) - expect(handoff.state).toEqual({ - changes: optimizationData.changes, - profile: optimizationData.profile, - selectedOptimizations: optimizationData.selectedOptimizations, - }) - expect(element.props).toMatchObject({ - defaults: { consent: true, persistenceConsent: false }, - }) - }, - ) - - it.each([ - [ - 'accepted without persistence consent', - { - consent: { events: true }, - defaults: { consent: true, persistenceConsent: false }, - pageAccepted: true, - }, - ], - [ - 'accepted without data', - { - consent: { events: true, persistence: false }, - defaults: { consent: true, persistenceConsent: false }, - pageAccepted: true, - }, - ], - [ - 'blocked without data', - { - consent: false, - defaults: { consent: false, persistenceConsent: false }, - pageAccepted: false, - }, - ], - ] as const)( - 'creates request handoff from trusted forwarded no-data server result while browser owns page payload: %s', - async (_label, { consent, defaults, pageAccepted }) => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const getProfile = mockProfileFetch() - const { OptimizationRoot, createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - fetchOptions: { fetchMethod: getProfile }, - }) - - setForwardedServerData(request.headers, { - consent, - pageAccepted, - }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, - trustedRequestHandoff: true, - }) - const element = await OptimizationRoot({ children: 'Server content', handoff }) - - expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - expect(getProfile).not.toHaveBeenCalled() - expect(handoff.initialPageEvent).toBe(pageAccepted ? 'skip' : 'emit') - expect(handoff.cache).toEqual({ scope: 'private-request' }) - expect(handoff.state).toBeUndefined() - expect(element.props).toMatchObject({ defaults }) - }, - ) - - it('ignores raw forwarded server data without trusted opt-in', async () => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const { createRequestHandoff } = bindNextjsAppRouterServerOptimization({ + it('resolves configured initial events once and binds replay to the visible route', async () => { + setCurrentNextRequest('https://example.test/products?search=two%20words&_rsc=123') + const { preview } = mockRequestPreview() + const initialEvents = rs.fn(({ routeKey }: { routeKey: string }) => [ + { type: 'identify' as const, userId: 'test-user', traits: { routeKey } }, + { type: 'track' as const, event: 'Viewed campaign' }, + ]) + const pagePayload = rs.fn(() => ({ properties: { url: 'https://analytics.test/other' } })) + const { request } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, - consent: { server: true }, + allowedEventTypes: ['identify', 'track', 'page'], + request: { initialEvents, pagePayload }, }) - - request.headers.set( - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext({ - consent: false, - pageAccepted: false, - profileId: 'a19c3f54d2b84e37a93f6d1c0e5b7284', - }), + const [provider, tracker] = await Promise.all([ + request.OptimizationProvider({ children: null }), + request.NextAppAutoPageTracker({}), + ]) + expect(initialEvents).toHaveBeenCalledTimes(1) + expect(pagePayload).toHaveBeenCalledTimes(1) + expect(initialEvents).toHaveBeenCalledWith( + expect.objectContaining({ routeKey: '/products?search=two+words' }), ) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, + expect(preview).toHaveBeenCalledTimes(1) + const sentEvents = preview.mock.calls[0]?.[0].events + expect(sentEvents?.map(({ type }) => type)).toEqual(['identify', 'track', 'page']) + const handoff = getElementProps(tracker).handoff + expect(handoff).toMatchObject({ + profileId: 'incoming-id', + replay: { routeKey: '/products?search=two+words', events: sentEvents }, }) - - expect(forRequest).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) - expect(handoff.initialPageEvent).toBe('skip') + expect(provider && getElementProps(provider).handoff).toBe(handoff) + expect(preview).toHaveBeenCalledWith( + expect.objectContaining({ profileId: 'incoming-id' }), + expect.objectContaining({ preflight: true, locale: 'en-US' }), + ) }) - it('ignores forwarded server data without a pageAccepted signal', async () => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const { createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - consent: { server: true }, + it('returns baseline rendering and a prepared replay when preview fails', async () => { + setCurrentNextRequest() + const { preview } = mockRequestPreview() + preview.mockRejectedValue(new Error('Preview unavailable')) + const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) + const [entry, tracker] = await Promise.all([ + request.OptimizedEntry({ + baselineEntry: optimizedEntry, + children: (resolved) => resolved.sys.id, + }), + request.NextAppAutoPageTracker({}), + ]) + expect(await renderToHtml(entry)).toContain(baselineEntry.sys.id) + expect(getElementProps(tracker).handoff).toMatchObject({ + profileId: 'incoming-id', + replay: { + routeKey: '/products?tab=featured', + events: [expect.objectContaining({ type: 'page' })], + }, }) + }) - setForwardedServerData(request.headers, { - consent: false, - pageAccepted: undefined, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', + it('shares configured consent and cookie options with its identity-only handler', async () => { + const { preview } = mockRequestPreview() + const { requestHandler } = bindNextjsAppRouterServerOptimization({ + ...sdkConfig, + cookie: { + domain: 'example.test', + expires: 1, + path: '/products', + sameSite: 'strict', + secure: true, + }, }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, - trustedRequestHandoff: true, + const request = new NextRequest('https://example.test/products') + request.cookies.set('ctfl-opt-aid', 'incoming-id') + const response = await requestHandler(request) + expect(preview).not.toHaveBeenCalled() + expect(response.cookies.get('ctfl-opt-aid')).toMatchObject({ + value: 'incoming-id', + domain: 'example.test', + path: '/products', + sameSite: 'strict', + secure: true, + maxAge: 86400, + httpOnly: false, }) - - expect(forRequest).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) - expect(handoff.initialPageEvent).toBe('skip') }) it('rejects public request handoff cache metadata before request evaluation', async () => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) + const { forRequest, preview } = mockRequestPreview() const { createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) await expect( @@ -1112,14 +899,13 @@ describe('Next.js App Router v2 binding', () => { 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', ) expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() + expect(preview).not.toHaveBeenCalled() }) it('creates analytics-only public permutation handoffs without mounting content personalization', () => { const { OptimizationAnalyticsRoot } = bindNextjsAppRouterServerOptimization(sdkConfig) const handoff = createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], }) @@ -1145,7 +931,6 @@ describe('Next.js App Router v2 binding', () => { it('preserves caller-owned public permutation tags', () => { const handoff = createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], tags: ['segment-a', 'products'], @@ -1159,7 +944,6 @@ describe('Next.js App Router v2 binding', () => { createStandaloneHandoffFromSelections({ cache: { key: 'segment-a', scope: 'public-permutation', tags: ['segment,a'] }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }), ).toThrow(TypeError) @@ -1175,7 +959,6 @@ describe('Next.js App Router v2 binding', () => { expect(() => createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], tags, @@ -1190,13 +973,11 @@ describe('Next.js App Router v2 binding', () => { createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations, }) const handoff = createStandaloneHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations, }) OptimizationAnalyticsRoot({ @@ -1245,7 +1026,6 @@ describe('Next.js App Router v2 binding', () => { createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'empty-variant' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: emptyVariantSelectedOptimizations, }) const html = await renderToHtml( @@ -1275,7 +1055,7 @@ describe('Next.js App Router v2 binding', () => { }) it('resolves server OptimizedEntry from request handoff selections', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const { OptimizationRoot, OptimizedEntry, createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) @@ -1306,7 +1086,7 @@ describe('Next.js App Router v2 binding', () => { }) it('defaults server merge-tag helpers to the request handoff profile', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const mergeTagEntry = createMergeTagEntry('merge-tag', 'traits.continent') const { OptimizationRoot, OptimizedEntry, createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) @@ -1341,7 +1121,6 @@ describe('Next.js App Router v2 binding', () => { const handoff = createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations, }) @@ -1381,7 +1160,6 @@ describe('Next.js App Router v2 binding', () => { createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations, }) cdaRelease.resolve(optimizedEntry) @@ -1394,7 +1172,7 @@ describe('Next.js App Router v2 binding', () => { }) it('uses request handoff selections when resolving managed server entries', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const getEntry = rs.fn(async () => await Promise.resolve(optimizedEntry)) const getEntries = rs.fn(async () => await Promise.resolve(createEntryCollection([]))) const { OptimizationRoot, OptimizedEntry, createRequestHandoff } = @@ -1431,7 +1209,7 @@ describe('Next.js App Router v2 binding', () => { }) it('resolves slug-managed server entries with request selections and tracking IDs', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const getEntry = rs.fn(async () => await Promise.resolve(createEntry('unused'))) const getEntries = rs.fn( async () => await Promise.resolve(createEntryCollection([optimizedEntry])), @@ -1493,7 +1271,7 @@ describe('Next.js App Router v2 binding', () => { }) it('makes request handoff consent and selections available during server render', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview() const { OptimizationRoot, createRequestHandoff } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, consent: { server: true, clientDefaults: { consent: false, persistenceConsent: false } }, @@ -1531,7 +1309,6 @@ describe('Next.js App Router v2 binding', () => { const handoff = createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx index 0c2805f0f..70f4d403b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx @@ -32,6 +32,7 @@ import type { NextjsBoundOptimizedEntryProps, NextjsBoundProviderConfig, } from './bound-component-types' +import { toNextjsAnonymousIdCookieOptions } from './cookies' import { createHandoffFromSelections, createOptimizationCacheKey, @@ -44,6 +45,10 @@ import { type NextjsCreateHandoffFromSelectionsOptions, type NextjsCreatePublicPermutationHandoffOptions, } from './handoff' +import { + createNextjsOptimizationContextHandler, + type NextjsOptimizationRequestHandler, +} from './request-handler' import { configureNextjsServerOptimization, type OptimizationNodeConfig } from './server' import { renderOptimizedEntryOnServer, @@ -106,6 +111,7 @@ export interface NextjsAppRouterServerOptimization { readonly OptimizedEntry: NextjsBoundOptimizedEntryComponent> readonly NextAppAutoPageTracker: typeof NextAppAutoPageTracker readonly request: NextjsAppRouterRequestOptimization + readonly requestHandler: NextjsOptimizationRequestHandler readonly createRequestHandoff: ( options: AppRouterCreateRequestHandoffOptions, ) => Promise @@ -134,7 +140,7 @@ export function bindNextjsAppRouterServerOptimization( ): NextjsAppRouterServerOptimization { const sdk = configureNextjsServerOptimization(toServerOptimizationConfig(config)) const rootConfig = toClientRootConfig(config) - const providerConfig = toClientProviderConfig(config) + const { liveUpdates: _liveUpdates, ...providerConfig } = rootConfig const analyticsRootConfig = providerConfig function createBoundHandoffFromSelections( @@ -241,13 +247,14 @@ export function bindNextjsAppRouterServerOptimization( handoff, hydration, prefetchManagedEntries, + routeKey, }: BoundNextjsOptimizationProviderProps): Promise { const effectiveHandoff = await resolveHandoffEntries(handoff, prefetchManagedEntries) rememberRequestHandoff(effectiveHandoff) return createElement( ReactWebOptimizationProvider, - { ...withRequestDefaults(providerConfig), handoff: effectiveHandoff, hydration }, + { ...withRequestDefaults(providerConfig), handoff: effectiveHandoff, hydration, routeKey }, createElement( ReactWebLiveUpdatesProvider, { globalLiveUpdates: config.liveUpdates }, @@ -290,7 +297,6 @@ export function bindNextjsAppRouterServerOptimization( cache: { scope: 'static' }, entries, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) } @@ -411,6 +417,12 @@ export function bindNextjsAppRouterServerOptimization( createPublicPermutationHandoff: createBoundPublicPermutationHandoff, createRequestHandoff, request, + requestHandler: createNextjsOptimizationContextHandler({ + sdk, + consent: config.consent?.server ?? false, + locale: config.locale, + cookieOptions: toNextjsAnonymousIdCookieOptions(config.cookie), + }), resolveEntriesForSelections, } } @@ -470,24 +482,10 @@ function toServerOptimizationConfig( function toClientRootConfig( config: NextjsAppRouterServerOptimizationConfig, ): NextjsBoundProviderConfig & Pick { - const { - consent, - contentful: _contentful, - cookie: _cookie, - request: _request, - ...clientConfig - } = config + const { consent, contentful: _contentful, request: _request, ...clientConfig } = config return { ...clientConfig, defaults: consent?.clientDefaults, } } - -function toClientProviderConfig( - config: NextjsAppRouterServerOptimizationConfig, -): NextjsBoundProviderConfig { - const { liveUpdates: _liveUpdates, ...rootConfig } = toClientRootConfig(config) - - return rootConfig -} diff --git a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts index 4aeb73544..243253d31 100644 --- a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts +++ b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts @@ -7,7 +7,11 @@ import type { OptimizedEntryManagedProps, OptimizedEntryProps, } from '@contentful/optimization-react-web' -import type { StatefulDefaults } from '@contentful/optimization-react-web/core-sdk' +import type { + InitialExperienceEvent, + PageViewBuilderArgs, + StatefulDefaults, +} from '@contentful/optimization-react-web/core-sdk' import type { NextAppAutoPageTrackerProps } from '@contentful/optimization-react-web/router/next-app' import type { ChainModifiers, EntrySkeletonType, LocaleCode } from 'contentful' import type { ReactElement, ReactNode } from 'react' @@ -15,7 +19,7 @@ import type { ContentOptimizationHandoff, ContentOptimizationHydrationMode } fro export type NextjsBoundProviderConfig = Omit< OptimizationProviderConfigProps, - 'children' | 'cookie' | 'handoff' | 'hydration' | 'prefetchManagedEntries' | 'sdk' + 'children' | 'handoff' | 'hydration' | 'prefetchManagedEntries' | 'routeKey' | 'sdk' > export interface NextjsCookieValue { @@ -42,17 +46,14 @@ export type NextjsOptimizationServerConsentResolver = ( context: NextjsOptimizationServerConsentContext, ) => NextjsOptimizationServerConsent | Promise -export interface NextjsOptimizationCookieConfig { - readonly domain?: string - readonly expires?: number -} +export type NextjsOptimizationCookieConfig = NonNullable export interface NextjsOptimizationConsentConfig { readonly server?: NextjsOptimizationServerConsent | NextjsOptimizationServerConsentResolver readonly clientDefaults?: NextjsPagesRouterClientDefaults } -export type NextjsBoundRootConfig = Omit & { +export type NextjsBoundRootConfig = Omit & { readonly consent?: NextjsOptimizationConsentConfig readonly cookie?: NextjsOptimizationCookieConfig } & Pick @@ -73,7 +74,7 @@ export type NextjsClientOptimizationConfig = | NextjsClientOptimizationConfigWithoutBeforeInitialPage | NextjsClientOptimizationConfigWithBeforeInitialPage -export interface NextjsAppRouterRequestContext { +export interface NextjsAppRouterRequestContext extends NextjsOptimizationServerConsentContext { readonly requestUrl: string readonly routeKey: string } @@ -84,7 +85,16 @@ export type NextjsAppRouterRequestHydration = export interface NextjsAppRouterRequestConfig { readonly hydration?: NextjsAppRouterRequestHydration - readonly trustedRequestHandoff?: true + readonly initialEvents?: + | readonly InitialExperienceEvent[] + | (( + context: NextjsAppRouterRequestContext, + ) => readonly InitialExperienceEvent[] | Promise) + readonly pagePayload?: + | PageViewBuilderArgs + | (( + context: NextjsAppRouterRequestContext, + ) => PageViewBuilderArgs | Promise) } export interface NextjsAppRouterServerOptimizationConfig extends NextjsOptimizationComponentsConfig { @@ -139,7 +149,7 @@ export interface NextjsBoundOptimizedEntryComponent { export interface BoundNextjsOptimizationProviderProps extends Pick< OptimizationProviderConfigProps, - 'handoff' | 'hydration' | 'prefetchManagedEntries' + 'handoff' | 'hydration' | 'prefetchManagedEntries' | 'routeKey' > { readonly children?: ReactNode } @@ -172,12 +182,12 @@ export type NextjsAppRouterRequestOptimizationRootProps = Omit< export type NextjsAppRouterRequestOptimizationProviderProps = Omit< BoundNextjsOptimizationProviderProps, - 'handoff' | 'hydration' + 'handoff' | 'hydration' | 'routeKey' > export type NextjsAppRouterRequestAutoPageTrackerProps = Omit< NextAppAutoPageTrackerProps, - 'initialPageEvent' + 'handoff' > export interface NextjsAppRouterRequestOptimization { diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index bd7865b1d..18047b7da 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -30,7 +30,6 @@ export type { BeforeInitialPageClient, BeforeInitialPageOptions, ContentfulOptimizationOrNull, - InitialAutoPageEvent, LiveUpdatesContextValue, LiveUpdatesProviderProps, ManagedEntryDescriptor, diff --git a/packages/web/frameworks/nextjs-sdk/src/cookies.ts b/packages/web/frameworks/nextjs-sdk/src/cookies.ts index 89311caac..0265c2e23 100644 --- a/packages/web/frameworks/nextjs-sdk/src/cookies.ts +++ b/packages/web/frameworks/nextjs-sdk/src/cookies.ts @@ -3,7 +3,10 @@ import { type CoreStatelessRequest, type OptimizationData, } from '@contentful/optimization-react-web/core-sdk' -import type { NextjsCookieReader } from './bound-component-types' +import type { NextjsCookieReader, NextjsOptimizationCookieConfig } from './bound-component-types' +import type { NextjsResponseLike } from './server' + +const SECONDS_IN_DAY = 86_400 export const DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE = ANONYMOUS_ID_COOKIE @@ -56,33 +59,42 @@ export function createCookieReaderFromHeader( } export function createNextjsAnonymousIdSetCookieHeader( + requestOptimization: CoreStatelessRequest, + data: OptimizationData | undefined, + options: PersistNextjsAnonymousIdOptions = {}, +): string | undefined { + const cookie = resolveAnonymousIdCookie(requestOptimization, data, options) + return cookie === undefined + ? undefined + : serializeCookie(cookie.name, cookie.value, cookie.options) +} + +function resolveAnonymousIdCookie( requestOptimization: CoreStatelessRequest, data: OptimizationData | undefined, { anonymousIdCookieName = DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, cookieOptions, deleteWhenProfileCannotPersist = true, - }: PersistNextjsAnonymousIdOptions = {}, -): string | undefined { + }: PersistNextjsAnonymousIdOptions, +): + | { + readonly name: string + readonly value: string + readonly options: NextjsAnonymousIdCookieOptions + } + | undefined { const profileId = data?.profile.id ?? requestOptimization.profile?.id - - if (requestOptimization.canPersistProfile && profileId) { - return serializeCookie(anonymousIdCookieName, profileId, { - path: '/', - sameSite: 'lax', - ...cookieOptions, - }) + const options: NextjsAnonymousIdCookieOptions = { path: '/', sameSite: 'lax', ...cookieOptions } + if (requestOptimization.canPersistProfile) { + return profileId ? { name: anonymousIdCookieName, value: profileId, options } : undefined } - if (!deleteWhenProfileCannotPersist) return undefined - - return serializeCookie(anonymousIdCookieName, '', { - path: '/', - sameSite: 'lax', - ...cookieOptions, - expires: new Date(0), - maxAge: 0, - }) + return { + name: anonymousIdCookieName, + value: '', + options: { ...options, expires: new Date(0), maxAge: 0 }, + } } function readCookieHeaderValue(cookieHeader: string, cookieName: string): string | undefined { @@ -138,3 +150,26 @@ function serializeSameSite( return sameSite.slice(0, 1).toUpperCase() + sameSite.slice(1) } + +export function toNextjsAnonymousIdCookieOptions( + cookie: NextjsOptimizationCookieConfig | undefined, +): NextjsAnonymousIdCookieOptions | undefined { + if (cookie === undefined) return undefined + const { expires, ...attributes } = cookie + return { + ...attributes, + ...(typeof expires === 'number' && Number.isFinite(expires) + ? { maxAge: Math.trunc(expires * SECONDS_IN_DAY) } + : {}), + } +} + +export function persistNextjsAnonymousId( + response: NextjsResponseLike, + requestOptimization: CoreStatelessRequest, + data: OptimizationData | undefined, + options: PersistNextjsAnonymousIdOptions = {}, +): void { + const cookie = resolveAnonymousIdCookie(requestOptimization, data, options) + if (cookie !== undefined) response.cookies.set(cookie.name, cookie.value, cookie.options) +} diff --git a/packages/web/frameworks/nextjs-sdk/src/edge.test.ts b/packages/web/frameworks/nextjs-sdk/src/edge.test.ts index bc27c398b..e5828c71b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/edge.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/edge.test.ts @@ -1,8 +1,6 @@ -import { - CoreStateless, - type CoreStatelessRequest, - type OptimizationData, -} from '@contentful/optimization-react-web/core-sdk' +/** @rstest-environment node */ +import type { ExperienceApiClient } from '@contentful/optimization-react-web/api-client' +import { CoreStateless, type OptimizationData } from '@contentful/optimization-react-web/core-sdk' import { NextRequest } from 'next/server' import * as edgeExports from './edge' @@ -45,41 +43,34 @@ afterEach(() => { rs.restoreAllMocks() }) -function mockEdgeRequestPage( - result: Awaited> = { - accepted: true, - data: OPTIMIZATION_DATA, - }, -): { +function mockEdgeRequestPreview(): { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly preview: ReturnType> readonly runtimes: CoreStateless[] } { const originalForRequest = CoreStateless.prototype.forRequest - const page = rs.fn(async () => await Promise.resolve(result)) + const preview = rs.fn( + async () => await Promise.resolve(OPTIMIZATION_DATA), + ) const forRequest = rs.spyOn(CoreStateless.prototype, 'forRequest') const runtimes: CoreStateless[] = [] - forRequest.mockImplementation(function mockForRequest(this: CoreStateless, options) { runtimes.push(this) - const requestOptimization = originalForRequest.call(this, options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) - return requestOptimization + rs.spyOn(this.api.experience, 'upsertProfile').mockImplementation(preview) + return originalForRequest.call(this, options) }) - - return { forRequest, page, runtimes } + return { forRequest, preview, runtimes } } describe('Next.js Edge runtime helpers', () => { - it('exports the Edge configure helper without the removed create helper name', () => { + it('exports Edge configuration and public permutation helpers', () => { expect(edgeExports.configureNextjsEdgeOptimization).toBeTypeOf('function') expect(edgeExports.createPublicPermutationHandoff).toBeTypeOf('function') expect(edgeExports.createPublicPermutationCacheMetadata).toBeTypeOf('function') - expect(edgeExports).not.toHaveProperty('createNextjsEdgeOptimization') }) it('uses the build-time package version for Edge event library metadata', async () => { - const { runtimes } = mockEdgeRequestPage() + const { runtimes } = mockEdgeRequestPreview() const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization(SDK_CONFIG) await createEdgeRequestHandoff({ @@ -95,7 +86,7 @@ describe('Next.js Edge runtime helpers', () => { }) it('builds request handoff from a Web Request, reads cookies, and persists a Response cookie', async () => { - const { forRequest, page } = mockEdgeRequestPage() + const { forRequest, preview } = mockEdgeRequestPreview() const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ ...SDK_CONFIG, consent: { server: { events: true, persistence: true } }, @@ -115,9 +106,19 @@ describe('Next.js Edge runtime helpers', () => { request, }) - expect(result.handoff.initialPageEvent).toBe('skip') + expect(result.handoff.replay?.routeKey).toBe('/products?tab=featured') expect(result.handoff.state?.profile?.id).toBe('f0837d7dc6344c36a3a0a06c4cde754b') - expect(page).toHaveBeenCalledWith({ properties: { route: '/products' } }) + expect(preview).toHaveBeenCalledWith( + expect.objectContaining({ + events: [ + expect.objectContaining({ + type: 'page', + properties: expect.objectContaining({ route: '/products' }), + }), + ], + }), + expect.objectContaining({ preflight: true, locale: 'en-US' }), + ) expect(forRequest).toHaveBeenCalledWith( expect.objectContaining({ consent: { events: true, persistence: true }, @@ -149,8 +150,44 @@ describe('Next.js Edge runtime helpers', () => { expect(response.headers.get('set-cookie')).toContain('SameSite=Lax') }) + it('retains a replay and configured browser-readable cookie during preview failure', async () => { + const { preview } = mockEdgeRequestPreview() + preview.mockRejectedValue(new Error('Preview unavailable')) + const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ + ...SDK_CONFIG, + consent: { server: true }, + cookie: { + domain: 'example.com', + path: '/products', + expires: 1, + secure: true, + sameSite: 'strict', + }, + }) + const result = await createEdgeRequestHandoff({ + hydration: 'analytics-only', + request: new Request('https://example.com/products', { + headers: { cookie: 'ctfl-opt-aid=known-api-id' }, + }), + }) + const response = new Response('Server rendered content', { + headers: { 'set-cookie': 'app-cookie=1; Path=/' }, + }) + result.persist(response) + expect(result.data).toBeUndefined() + expect(result.handoff).toMatchObject({ + profileId: 'known-api-id', + replay: { routeKey: '/products', events: [expect.objectContaining({ type: 'page' })] }, + }) + expect(response.headers.get('set-cookie')).toContain('app-cookie=1') + expect(response.headers.get('set-cookie')).toContain( + 'ctfl-opt-aid=known-api-id; Max-Age=86400; Domain=example.com; Path=/products; Secure; SameSite=Strict', + ) + expect(response.headers.get('set-cookie')).not.toContain('HttpOnly') + }) + it('reads anonymous ID from framework cookie snapshots', async () => { - const { forRequest } = mockEdgeRequestPage() + const { forRequest } = mockEdgeRequestPreview() const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ ...SDK_CONFIG, consent: { server: true }, @@ -176,7 +213,7 @@ describe('Next.js Edge runtime helpers', () => { it.each([{ key: 'segment-a', scope: 'public-permutation' }, { scope: 'static' }] as const)( 'rejects $scope request handoff cache metadata before request evaluation', async (cache) => { - const { forRequest, page } = mockEdgeRequestPage() + const { forRequest, preview } = mockEdgeRequestPreview() const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ ...SDK_CONFIG, consent: { server: true }, @@ -194,29 +231,7 @@ describe('Next.js Edge runtime helpers', () => { 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', ) expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - }, - ) - - it.each([ - ['accepted without data', { accepted: true }, 'skip'], - ['blocked', { accepted: false }, 'emit'], - ] as const)( - 'sets initialPageEvent from page acceptance for %s', - async (_label, pageResult, expectedInitialPageEvent) => { - mockEdgeRequestPage(pageResult) - const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ - ...SDK_CONFIG, - consent: { server: true }, - }) - - const result = await createEdgeRequestHandoff({ - hydration: 'preserve-server', - pagePayload: {}, - request: new Request('https://example.com/products'), - }) - - expect(result.handoff.initialPageEvent).toBe(expectedInitialPageEvent) + expect(preview).not.toHaveBeenCalled() }, ) @@ -230,14 +245,12 @@ describe('Next.js Edge runtime helpers', () => { const handoff = createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }) const permutationHandoff = createPublicPermutationHandoff({ cacheVersion: 'version 1', entryIds: ['4ib0hsHWoSOnCVdDkizE8d'], hydration: 'preserve-server', - initialPageEvent: 'emit', locale: 'en-US', permutationKey: 'segment a', selectedOptimizations: [], @@ -252,7 +265,6 @@ describe('Next.js Edge runtime helpers', () => { expect(handoff).toEqual({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { selectedOptimizations: [] }, }) expect(permutationHandoff).toMatchObject({ @@ -261,7 +273,6 @@ describe('Next.js Edge runtime helpers', () => { scope: 'public-permutation', }, hydration: 'preserve-server', - initialPageEvent: 'emit', state: { selectedOptimizations: [] }, }) expect(permutationHandoff.cache.tags).toBeUndefined() diff --git a/packages/web/frameworks/nextjs-sdk/src/edge.ts b/packages/web/frameworks/nextjs-sdk/src/edge.ts index 9083d8625..76acb8cbd 100644 --- a/packages/web/frameworks/nextjs-sdk/src/edge.ts +++ b/packages/web/frameworks/nextjs-sdk/src/edge.ts @@ -1,6 +1,5 @@ import type { App } from '@contentful/optimization-react-web/api-schemas' import { - assertOptimizationCacheSafety, CoreStateless, createPageContextFromUrl, type CoreStatelessConfig, @@ -8,12 +7,11 @@ import { type CoreStatelessRequest, type CoreStatelessRequestConsent, type CoreStatelessRequestOptions, - type EventEmissionResult, type EventType, + type InitialExperienceEvent, type ManagedEntryHandoff, type OptimizationCacheMetadata, type OptimizationData, - type OptimizationHandoff, type PageViewBuilderArgs, type PartialProfile, type PrivateRequestOptimizationCacheMetadata, @@ -32,7 +30,7 @@ import { createNextjsAnonymousIdSetCookieHeader, DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, isNextjsCookieReader, - type NextjsAnonymousIdCookieOptions, + toNextjsAnonymousIdCookieOptions, type PersistNextjsAnonymousIdOptions, } from './cookies' import { @@ -50,7 +48,6 @@ const EDGE_SDK_NAME = '@contentful/optimization-nextjs' const EMPTY_COOKIE_READER = { get: () => undefined, } -const SECONDS_IN_DAY = 86_400 export type NextjsEdgeRequest = Request | NextjsEdgeRequestSnapshot @@ -79,7 +76,8 @@ export interface NextjsEdgeRequestHandoffOptions extends PersistNextjsAnonymousI readonly hydration: OptimizationHydrationMode readonly insightsOptions?: CoreStatelessInsightsOptions readonly locale?: string - readonly pagePayload: PageViewBuilderArgs + readonly pagePayload?: PageViewBuilderArgs + readonly initialEvents?: readonly InitialExperienceEvent[] readonly profile?: PartialProfile readonly request: NextjsEdgeRequest } @@ -87,7 +85,6 @@ export interface NextjsEdgeRequestHandoffOptions extends PersistNextjsAnonymousI export interface NextjsEdgeRequestHandoff { readonly data: OptimizationData | undefined readonly handoff: BrowserOptimizationHandoff - readonly pageResult: EventEmissionResult readonly persist: (response: Response) => void readonly requestOptimization: CoreStatelessRequest } @@ -127,28 +124,32 @@ export function configureNextjsEdgeOptimization( locale: options.locale ?? config.locale, profile, }) - const pageResult = await requestOptimization.page(options.pagePayload) - const { data } = pageResult + const url = new URL(request.url) + const { handoff: prepared, data } = await requestOptimization.prepareRequestHandoff({ + routeKey: `${url.pathname}${url.search}`, + initialEvents: options.initialEvents, + page: options.pagePayload, + }) const handoff = addBrowserHandoffMetadata( - createEdgeRequestOptimizationHandoff({ + { + ...prepared, cache, - data, entries: options.entries, - }), - { - hydration: options.hydration, - initialPageEvent: pageResult.accepted ? 'skip' : 'emit', + state: data, }, + { hydration: options.hydration }, ) return { data, handoff, - pageResult, persist: (response) => { persistEdgeAnonymousId(response, requestOptimization, data, { anonymousIdCookieName: options.anonymousIdCookieName, - cookieOptions: options.cookieOptions ?? toConfigCookieOptions(config.cookie), + cookieOptions: { + ...(options.cookieOptions ?? toNextjsAnonymousIdCookieOptions(config.cookie)), + httpOnly: false, + }, deleteWhenProfileCannotPersist: options.deleteWhenProfileCannotPersist, }) }, @@ -238,30 +239,6 @@ function mergeEdgeRequestPage( return eventPage === undefined ? requestPage : { ...requestPage, ...eventPage } } -function createEdgeRequestOptimizationHandoff(input: { - readonly cache?: PrivateRequestOptimizationCacheMetadata - readonly data?: OptimizationData - readonly entries?: readonly ManagedEntryHandoff[] -}): OptimizationHandoff { - const handoff: OptimizationHandoff = { - cache: input.cache ?? { scope: 'private-request' }, - ...(input.entries === undefined ? {} : { entries: input.entries }), - ...(input.data === undefined - ? {} - : { - state: { - changes: input.data.changes, - profile: input.data.profile, - selectedOptimizations: input.data.selectedOptimizations, - }, - }), - } - - assertOptimizationCacheSafety(handoff) - - return handoff -} - function assertEdgeRequestHandoffCacheMetadata( cache: OptimizationCacheMetadata, ): asserts cache is PrivateRequestOptimizationCacheMetadata { @@ -293,19 +270,6 @@ function persistEdgeAnonymousId( response.headers.append('set-cookie', setCookie) } -function toConfigCookieOptions( - cookie: NextjsOptimizationCookieConfig | undefined, -): NextjsAnonymousIdCookieOptions | undefined { - if (cookie === undefined) return undefined - - return { - ...(cookie.domain ? { domain: cookie.domain } : {}), - ...(typeof cookie.expires === 'number' && Number.isFinite(cookie.expires) - ? { maxAge: Math.trunc(cookie.expires * SECONDS_IN_DAY) } - : {}), - } -} - export { createHandoffFromSelections, createOptimizationCacheKey, diff --git a/packages/web/frameworks/nextjs-sdk/src/handoff.ts b/packages/web/frameworks/nextjs-sdk/src/handoff.ts index 534ed3475..a498f56c4 100644 --- a/packages/web/frameworks/nextjs-sdk/src/handoff.ts +++ b/packages/web/frameworks/nextjs-sdk/src/handoff.ts @@ -28,11 +28,8 @@ export type { export { createOptimizationCacheKey } -export type NextjsInitialPageEvent = 'emit' | 'skip' - export interface NextjsBrowserHandoffMetadata { readonly hydration: OptimizationHydrationMode - readonly initialPageEvent: NextjsInitialPageEvent } export interface NextjsCreateHandoffFromSelectionsOptions extends NextjsBrowserHandoffMetadata { @@ -86,7 +83,6 @@ export function addBrowserHandoffMetadata( const browserHandoff: BrowserOptimizationHandoff = { ...handoff, hydration: metadata.hydration, - initialPageEvent: metadata.initialPageEvent, } return browserHandoff @@ -106,7 +102,7 @@ export function createHandoffFromSelections( export function createHandoffFromSelections( input: NextjsCreateHandoffFromSelectionsOptions, ): BrowserOptimizationHandoff { - const { hydration, initialPageEvent, selectedOptimizations, changes, entries, cache } = input + const { hydration, selectedOptimizations, changes, entries, cache } = input if (cache.scope === 'public-permutation') validateNextjsPublicPermutationCacheTags(cache.tags) const handoff = createCoreHandoffFromSelections({ @@ -116,7 +112,7 @@ export function createHandoffFromSelections( selectedOptimizations, }) - return addBrowserHandoffMetadata(handoff, { hydration, initialPageEvent }) + return addBrowserHandoffMetadata(handoff, { hydration }) } export function createPublicPermutationHandoff( @@ -138,7 +134,6 @@ export function createPublicPermutationHandoff( changes: input.changes, entries: input.entries, hydration: input.hydration, - initialPageEvent: input.initialPageEvent, selectedOptimizations: input.selectedOptimizations, }) } diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts index 9f9c3fbd2..51c62a8db 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts @@ -1,4 +1,5 @@ import ContentfulOptimizationRuntime from '@contentful/optimization-node' +import type { ExperienceApiClient } from '@contentful/optimization-node/api-client' import type { Entry } from 'contentful' import type { GetServerSidePropsContext } from 'next' import { IncomingMessage, ServerResponse } from 'node:http' @@ -7,7 +8,6 @@ import * as pagesRouterServerExports from './pages-router-server' import { configureNextjsServerOptimization, type ContentfulOptimization, - type CoreStatelessRequest, type OptimizationData, } from './server' @@ -53,14 +53,14 @@ afterEach(() => { interface CreatedSdk { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly preview: ReturnType> readonly sdk: ContentfulOptimization } type NextjsOptimizationConfig = Parameters[0] function createSdk( - page = rs.fn( - async () => await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }), + preview = rs.fn( + async () => await Promise.resolve(OPTIMIZATION_DATA), ), config: NextjsOptimizationConfig = SDK_CONFIG, ): CreatedSdk { @@ -70,20 +70,20 @@ function createSdk( forRequest.mockImplementation((options) => { const requestOptimization = originalForRequest(options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) + rs.spyOn(sdk.api.experience, 'upsertProfile').mockImplementation(preview) return requestOptimization }) - return { forRequest, page, sdk } + return { forRequest, preview, sdk } } -function mockPrototypeRequestPage(): { +function mockPrototypeRequestPreview(): { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly preview: ReturnType> } { const originalForRequest = ContentfulOptimizationRuntime.prototype.forRequest - const page = rs.fn( - async () => await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }), + const preview = rs.fn( + async () => await Promise.resolve(OPTIMIZATION_DATA), ) const forRequest = rs.spyOn(ContentfulOptimizationRuntime.prototype, 'forRequest') @@ -92,11 +92,11 @@ function mockPrototypeRequestPage(): { options, ) { const requestOptimization = originalForRequest.call(this, options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) + rs.spyOn(this.api.experience, 'upsertProfile').mockImplementation(preview) return requestOptimization }) - return { forRequest, page } + return { forRequest, preview } } function createEntry(id: string): Entry { @@ -167,16 +167,15 @@ function createContext({ } describe('Next.js Pages Router server handoff helpers', () => { - it('exports the server binding helper without the removed Pages Router create* name', () => { + it('exports server binding and public permutation helpers', () => { expect(pagesRouterServerExports.bindNextjsPagesRouterServerOptimization).toBeTypeOf('function') expect(pagesRouterServerExports.createPublicPermutationCacheMetadata).toBeTypeOf('function') expect(pagesRouterServerExports.createPublicPermutationHandoff).toBeTypeOf('function') expect(pagesRouterServerExports.resolveEntriesForSelections).toBeTypeOf('function') - expect(pagesRouterServerExports).not.toHaveProperty('createNextjsPagesRouterOptimization') }) it('creates a config-bound request handoff helper', async () => { - const { forRequest } = mockPrototypeRequestPage() + const { forRequest } = mockPrototypeRequestPreview() const resolveConsent = rs.fn( (context: { readonly cookies: { get: (name: string) => unknown } }) => context.cookies.get('consent') ? { events: true, persistence: true } : false, @@ -201,7 +200,6 @@ describe('Next.js Pages Router server handoff helpers', () => { locale: 'de-DE', }), ) - expect(handoff.initialPageEvent).toBe('skip') expect(handoff).toMatchObject({ defaults: { consent: true, persistenceConsent: true }, }) @@ -210,8 +208,8 @@ describe('Next.js Pages Router server handoff helpers', () => { ) }) - it('builds request context from getServerSideProps context and calls page', async () => { - const { forRequest, page, sdk } = createSdk() + it('builds request context from getServerSideProps context and previews the page', async () => { + const { forRequest, preview, sdk } = createSdk() const result = await createNextjsPagesRouterRequestHandoff( sdk, @@ -231,7 +229,7 @@ describe('Next.js Pages Router server handoff helpers', () => { }, ) - expect(result.handoff.initialPageEvent).toBe('skip') + expect(result.handoff.replay?.routeKey).toBe('/products?tab=featured') expect(result.handoff.state?.profile?.id).toBe('f0837d7dc6344c36a3a0a06c4cde754b') expect(forRequest).toHaveBeenCalledWith( expect.objectContaining({ @@ -250,26 +248,37 @@ describe('Next.js Pages Router server handoff helpers', () => { locale: 'de-DE', }), ) - expect(page).toHaveBeenCalledWith({ properties: { route: '/products' } }) + expect(preview).toHaveBeenCalledWith( + expect.objectContaining({ + events: [ + expect.objectContaining({ + type: 'page', + properties: expect.objectContaining({ route: '/products' }), + }), + ], + }), + expect.objectContaining({ preflight: true, locale: 'de-DE' }), + ) }) - it.each([ - ['accepted without data', { accepted: true }, 'skip'], - ['blocked', { accepted: false }, 'emit'], - ] as const)( - 'sets initialPageEvent from page acceptance for %s', - async (_label, pageResult, expectedInitialPageEvent) => { - const { sdk } = createSdk( - rs.fn(async () => await Promise.resolve(pageResult)), - ) - - const result = await createNextjsPagesRouterRequestHandoff(sdk, createContext(), { + it.each([undefined, 'known-api-id'])( + 'retains replay and known identity on preview failure (%s)', + async (knownId) => { + const { sdk, preview } = createSdk() + preview.mockRejectedValue(new Error('Preview unavailable')) + const context = createContext({ cookies: knownId ? { 'ctfl-opt-aid': knownId } : {} }) + const result = await createNextjsPagesRouterRequestHandoff(sdk, context, { consent: true, hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, }) - - expect(result.handoff.initialPageEvent).toBe(expectedInitialPageEvent) + expect(result.data).toBeUndefined() + expect(result.handoff.profileId).toBe(knownId) + expect(result.handoff.replay).toMatchObject({ + routeKey: '/products?tab=featured', + events: [expect.objectContaining({ type: 'page' })], + }) + if (knownId) expect(context.res.getHeader('Set-Cookie')).toContain(`ctfl-opt-aid=${knownId}`) + else expect(context.res.getHeader('Set-Cookie')).toBeUndefined() }, ) @@ -308,7 +317,7 @@ describe('Next.js Pages Router server handoff helpers', () => { ) }) - it('prefetches declared managed entries into handoff entries after request data loads', async () => { + it('prefetches declared managed entries into handoff entries alongside preview', async () => { const calls: string[] = [] const baselineEntry = createEntry('4ib0hsHWoSOnCVdDkizE8d') const existingEntry = createEntry('4k6ZyFQnR2POY5IJLLlJRb') @@ -317,11 +326,11 @@ describe('Next.js Pages Router server handoff helpers', () => { calls.push('fetch') return await Promise.resolve(createEntryCollection([baselineEntry])) }) - const page = rs.fn(async () => { - calls.push('page') - return await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }) + const preview = rs.fn(async () => { + calls.push('preview') + return await Promise.resolve(OPTIMIZATION_DATA) }) - const { sdk } = createSdk(page, { + const { sdk } = createSdk(preview, { ...SDK_CONFIG, contentful: { client: { getEntry, getEntries }, cache: false }, }) @@ -347,7 +356,7 @@ describe('Next.js Pages Router server handoff helpers', () => { ], }) - expect(calls).toEqual(['page', 'fetch']) + expect(calls).toEqual(['fetch', 'preview']) expect(getEntry).not.toHaveBeenCalled() expect(getEntries).toHaveBeenCalledTimes(1) expect(getEntries).toHaveBeenCalledWith({ diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts index 569a6657a..759b15e9a 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts @@ -4,7 +4,6 @@ import type { IncomingHttpHeaders } from 'node:http' import { toHandoffDefaults } from './app-router-request-handoff' import type { NextjsOptimizationComponentsConfig, - NextjsOptimizationCookieConfig, NextjsOptimizationServerConsent, NextjsOptimizationServerConsentResolver, } from './bound-component-types' @@ -12,6 +11,7 @@ import { createCookieReaderFromHeader, createCookieReaderFromRecord, createNextjsAnonymousIdSetCookieHeader, + toNextjsAnonymousIdCookieOptions, type PersistNextjsAnonymousIdOptions, } from './cookies' import type { BrowserOptimizationHandoff } from './handoff' @@ -29,7 +29,6 @@ import { type OptimizationNodeConfig, } from './server' -const SECONDS_IN_DAY = 86_400 const EMPTY_COOKIE_READER = { get: () => undefined, } @@ -77,7 +76,7 @@ export function bindNextjsPagesRouterServerOptimization( const { handoff } = await createNextjsPagesRouterRequestHandoff(sdk, context, { ...options, consent, - cookieOptions: options.cookieOptions ?? toAnonymousIdCookieOptions(config.cookie), + cookieOptions: options.cookieOptions ?? toNextjsAnonymousIdCookieOptions(config.cookie), locale: options.locale ?? config.locale ?? context.locale, }) @@ -101,6 +100,14 @@ export async function createNextjsPagesRouterRequestHandoff( ...requestOptions } = options const request = createPagesRouterRequest(context) + const entriesPromise = + prefetchManagedEntries === undefined + ? undefined + : prefetchServerManagedEntries( + sdk.forRequest({ consent: requestOptions.consent, locale: locale ?? context.locale }), + prefetchManagedEntries, + ) + void entriesPromise?.catch(() => undefined) const result = await createNextjsRequestHandoff(sdk, { ...requestOptions, locale: locale ?? context.locale, @@ -112,7 +119,7 @@ export async function createNextjsPagesRouterRequestHandoff( result.data, { anonymousIdCookieName: requestOptions.anonymousIdCookieName, - cookieOptions, + cookieOptions: { ...cookieOptions, httpOnly: false }, deleteWhenProfileCannotPersist, }, ) @@ -125,10 +132,7 @@ export async function createNextjsPagesRouterRequestHandoff( } } - const entries = await prefetchServerManagedEntries( - result.requestOptimization, - prefetchManagedEntries, - ) + const entries = (await entriesPromise) ?? [] const handoff: PagesRouterRequestDefaultsHandoff = { ...requestHandoff, @@ -273,18 +277,3 @@ function toServerOptimizationConfig( return serverConfig as OptimizationNodeConfig } - -function toAnonymousIdCookieOptions( - cookie: NextjsOptimizationCookieConfig | undefined, -): PersistNextjsAnonymousIdOptions['cookieOptions'] { - if (cookie === undefined) return undefined - - const cookieOptions: NonNullable = { - ...(cookie.domain ? { domain: cookie.domain } : {}), - ...(typeof cookie.expires === 'number' && Number.isFinite(cookie.expires) - ? { maxAge: Math.trunc(cookie.expires * SECONDS_IN_DAY) } - : {}), - } - - return Object.keys(cookieOptions).length === 0 ? undefined : cookieOptions -} diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx index d3ffdcaf7..b42d9c9b1 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx @@ -55,6 +55,13 @@ describe('Next.js Pages Router client components', () => { ...testConfig, consent: { clientDefaults: { consent: false, persistenceConsent: false } }, contentful, + cookie: { + path: '/products', + sameSite: 'strict', + secure: true, + domain: 'example.test', + expires: 1, + }, liveUpdates: true, }) const handoff = components.createHandoffFromSelections({ @@ -63,7 +70,6 @@ describe('Next.js Pages Router client components', () => { { baselineEntry: createEntry('4ib0hsHWoSOnCVdDkizE8d'), entryId: '4ib0hsHWoSOnCVdDkizE8d' }, ], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -81,6 +87,13 @@ describe('Next.js Pages Router client components', () => { }) expect(root.props).toMatchObject({ + cookie: { + path: '/products', + sameSite: 'strict', + secure: true, + domain: 'example.test', + expires: 1, + }, api: testConfig.api, children: 'Root content', spaceId: testConfig.spaceId, @@ -124,7 +137,6 @@ describe('Next.js Pages Router client components', () => { ...components.createHandoffFromSelections({ cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', selectedOptimizations: [], }), defaults: { consent: true }, @@ -133,7 +145,6 @@ describe('Next.js Pages Router client components', () => { ...components.createHandoffFromSelections({ cache: { scope: 'private-request' }, hydration: 'analytics-only', - initialPageEvent: 'skip', selectedOptimizations: [], }), defaults: { consent: true }, @@ -188,7 +199,6 @@ describe('Next.js Pages Router client components', () => { }, ], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -226,7 +236,6 @@ describe('Next.js Pages Router client components', () => { const analyticsHandoff = components.createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }) const root = components.OptimizationRoot({ diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router.ts index ee26c1362..345bfa0c1 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.ts @@ -137,11 +137,12 @@ export function bindNextjsPagesRouterOptimization( handoff, hydration, prefetchManagedEntries, + routeKey, }: BoundNextjsOptimizationProviderProps): ReactElement | null { return createElement( ReactWebOptimizationProvider, withRequestDefaults( - { ...providerConfig, handoff, hydration, prefetchManagedEntries }, + { ...providerConfig, handoff, hydration, prefetchManagedEntries, routeKey }, handoff, ), createElement( @@ -209,6 +210,7 @@ function toClientRootConfig( return { ...clientConfig, defaults: consent?.clientDefaults, + cookie: config.cookie, } } diff --git a/packages/web/frameworks/nextjs-sdk/src/request-context.ts b/packages/web/frameworks/nextjs-sdk/src/request-context.ts index d8ebe857b..4ca4eb85b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/request-context.ts +++ b/packages/web/frameworks/nextjs-sdk/src/request-context.ts @@ -1,18 +1,8 @@ export const NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX = 'x-ctfl-opt-' export const NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER = `${NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX}request-url` -export const NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER = `${NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX}server-data` -export function serializeNextjsOptimizationRequestContext(value: unknown): string { - return encodeURIComponent(JSON.stringify(value)) -} - -export function parseNextjsOptimizationRequestContext(value: string | null): unknown { - if (!value) return undefined - - try { - const parsed: unknown = JSON.parse(decodeURIComponent(value)) - return parsed - } catch (_error) { - return undefined - } +export function normalizeNextjsAppRequestUrl(requestUrl: string): string { + const url = new URL(requestUrl) + url.searchParams.delete('_rsc') + return url.toString() } diff --git a/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts b/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts index c57703e65..591fb8e5b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts @@ -1,66 +1,17 @@ +/** @rstest-environment node */ import { NextFetchEvent as NextFetchEventConstructor } from 'next/dist/server/web/spec-extension/fetch-event.js' import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server' -import { - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - parseNextjsOptimizationRequestContext, -} from './request-context' -import * as requestHandlerExports from './request-handler' import { createNextjsOptimizationContextHandler } from './request-handler' import { configureNextjsServerOptimization, type NextjsOptimizationServerConsentResolver, - type OptimizationData, } from './server' -type RemovedRequestHandlerPrefix = 'createNextjsOptimization' -type RemovedRequestHandlerSuffix = 'RequestHandler' -type RemovedRequestHandlerExportName = - `${RemovedRequestHandlerPrefix}${RemovedRequestHandlerSuffix}` -type RemovedRequestHandlerExportIsAbsent = - RemovedRequestHandlerExportName extends keyof typeof requestHandlerExports ? false : true - -const removedRequestHandlerExportIsAbsent: RemovedRequestHandlerExportIsAbsent = true -const removedRequestHandlerExportName = ['createNextjsOptimization', 'RequestHandler'].join('') - -const sdkConfig = { - spaceId: 'key_123', - environment: 'main', -} - -const optimizationData: OptimizationData = { - changes: [], - selectedOptimizations: [], - profile: { - id: 'f0837d7dc6344c36a3a0a06c4cde754b', - stableId: 'f0837d7dc6344c36a3a0a06c4cde754b', - random: 1, - audiences: [], - traits: {}, - location: {}, - session: { - id: 'e77eab64-93ca-4f6e-8492-037c1ff67caa', - isReturningVisitor: false, - landingPage: { - path: '/', - query: {}, - referrer: '', - search: '', - title: '', - url: 'https://example.test/', - }, - count: 1, - activeSessionLength: 0, - averageSessionLength: 0, - }, - }, -} +const sdkConfig = { spaceId: 'key_123', environment: 'main' } +const profileId = 'f0837d7dc6344c36a3a0a06c4cde754b' function createNextFetchEvent(request: NextRequest): NextFetchEvent { - return new NextFetchEventConstructor({ - context: { waitUntil: rs.fn() }, - page: '/', - request, - }) + return new NextFetchEventConstructor({ context: { waitUntil: rs.fn() }, page: '/', request }) } afterEach(() => { @@ -68,337 +19,175 @@ afterEach(() => { }) describe('createNextjsOptimizationContextHandler', () => { - it('exports only the context handler and not the removed page-producing request handler', () => { - expect(removedRequestHandlerExportIsAbsent).toBe(true) - expect(requestHandlerExports.createNextjsOptimizationContextHandler).toBeTypeOf('function') - expect(removedRequestHandlerExportName in requestHandlerExports).toBe(false) - }) - - it('forwards sanitized request URL context without performing SDK work', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') + it('forwards the visible URL and sanitized context without SDK work', async () => { const requestHandler = createNextjsOptimizationContextHandler() - const request = new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', - 'x-ctfl-opt-request-url': 'https://attacker.test/forged', - 'x-ctfl-opt-extra': 'forged-extra', - }, - }) - - const response = await requestHandler(request) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - - expect(response).toBeInstanceOf(Response) - expect(forwardedHeaders?.get('user-agent')).toBe('test-agent') - expect(forwardedHeaders?.get('x-ctfl-opt-extra')).toBeNull() - expect(forwardedHeaders?.get('x-ctfl-opt-request-url')).toBe( - 'https://example.com/products?tab=featured', + const response = await requestHandler( + new NextRequest('https://example.com/products?search=two%20words&_rsc=123', { + headers: { + 'user-agent': 'test-agent', + 'x-ctfl-opt-request-url': 'https://attacker.test/forged', + 'x-ctfl-opt-extra': 'forged', + }, + }), + ) + expect(response.headers.get('x-middleware-request-user-agent')).toBe('test-agent') + expect(response.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() + expect(response.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe( + 'https://example.com/products?search=two+words', ) - expect(Array.from(forwardedHeaders?.keys() ?? [])).toContain('user-agent') - expect(Array.from(forwardedHeaders?.keys() ?? [])).toContain('x-ctfl-opt-request-url') - expect(Array.from(forwardedHeaders?.keys() ?? [])).not.toContain('x-ctfl-opt-extra') }) - it('applies forwarded request context to an existing response while preserving response chain state', async () => { - const requestHandler = createNextjsOptimizationContextHandler() + it('preserves prior request overrides, response headers and cookies', async () => { const request = new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', - }, + headers: { 'user-agent': 'test-agent' }, }) - const existingRequestHeaders = new Headers(request.headers) - existingRequestHeaders.set('x-existing-request-handler', 'preserved') - existingRequestHeaders.set('x-ctfl-opt-extra', 'stale-sdk-context') - const existingResponse = NextResponse.next({ request: { headers: existingRequestHeaders } }) - existingResponse.headers.set('x-existing-handler', 'preserved') - existingResponse.headers.set( - 'x-middleware-override-headers', - Array.from(existingRequestHeaders.keys()).join(','), - ) - - for (const [name, value] of existingRequestHeaders) { - existingResponse.headers.set(`x-middleware-request-${name}`, value) - } - - const response = await requestHandler(request, existingResponse) - const overrideHeaders = response.headers.get('x-middleware-override-headers')?.split(',') - - expect(response).toBe(existingResponse) + const headers = new Headers(request.headers) + headers.set('x-existing-request-handler', 'preserved') + headers.set('x-ctfl-opt-extra', 'stale') + const prior = NextResponse.next({ request: { headers } }) + prior.headers.set('x-existing-handler', 'preserved') + prior.cookies.set('app-cookie', '1') + const response = await createNextjsOptimizationContextHandler()(request, prior) + expect(response).toBe(prior) expect(response.headers.get('x-existing-handler')).toBe('preserved') - expect(overrideHeaders).toContain('x-existing-request-handler') - expect(overrideHeaders).toContain('user-agent') - expect(overrideHeaders).toContain('x-ctfl-opt-request-url') - expect(overrideHeaders).not.toContain('x-ctfl-opt-extra') + expect(response.cookies.get('app-cookie')?.value).toBe('1') expect(response.headers.get('x-middleware-request-x-existing-request-handler')).toBe( 'preserved', ) - expect(response.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() - expect(response.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe( - 'https://example.com/products?tab=featured', + expect(response.headers.get('x-middleware-override-headers')?.split(',')).toContain( + 'x-existing-request-handler', ) + expect(response.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() }) - it('applies SDK context and cookie persistence to an existing rewrite response', async () => { + it('refreshes an API-issued identity cookie on a rewrite without evaluating Experience', async () => { const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: true }, + const upsert = rs.spyOn(sdk.api.experience, 'upsertProfile') + const get = rs.spyOn(sdk.api.experience, 'getProfile') + const handler = createNextjsOptimizationContextHandler({ + consent: true, sdk, - }) - const request = new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', + cookieOptions: { + domain: 'example.com', + path: '/products', + sameSite: 'strict', + secure: true, + maxAge: 86400, + httpOnly: true, }, }) - const rewriteUrl = new URL('/rewritten', request.url) - const existingResponse = new NextResponse(null) - existingResponse.headers.set('x-middleware-rewrite', rewriteUrl.toString()) - - const response = await requestHandler(request, existingResponse) - const context = parseNextjsOptimizationRequestContext( - response.headers.get(`x-middleware-request-${NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER}`), - ) - - expect(response).toBe(existingResponse) - expect(response.headers.get('x-middleware-rewrite')).toBe(rewriteUrl.toString()) - expect(response.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe( - 'https://example.com/products?tab=featured', - ) - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(context).toEqual({ - consent: { events: true, persistence: true }, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', + const request = new NextRequest('https://example.com/products', { + headers: { cookie: `ctfl-opt-aid=${profileId}` }, + }) + const prior = NextResponse.rewrite(new URL('/rewritten', request.url)) + const response = await handler(request, prior) + expect(response).toBe(prior) + expect(response.headers.get('x-middleware-rewrite')).toBe('https://example.com/rewritten') + expect(response.headers.get('x-middleware-request-cookie')).toBe(`ctfl-opt-aid=${profileId}`) + expect(response.cookies.get('ctfl-opt-aid')).toMatchObject({ + value: profileId, + domain: 'example.com', + path: '/products', + sameSite: 'strict', + secure: true, + maxAge: 86400, + httpOnly: false, }) - expect(response.cookies.get('ctfl-opt-aid')?.value).toBe('f0837d7dc6344c36a3a0a06c4cde754b') + expect(upsert).not.toHaveBeenCalled() + expect(get).not.toHaveBeenCalled() }) - it('preserves an existing JSON response without SDK work or request-header mutation', async () => { + it('leaves identity absent when no API-issued ID is known', async () => { const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockRejectedValue(new Error('terminal response should not call SDK')) - const consent = rs.fn(() => ({ events: true, persistence: true })) - const requestHandler = createNextjsOptimizationContextHandler({ consent, sdk }) - const terminalResponse = NextResponse.json({ error: 'unauthorized' }, { status: 401 }) - - const response = await requestHandler( - new NextRequest('https://example.com/products', { - headers: { - 'x-ctfl-opt-extra': 'stale-sdk-context', - }, - }), - terminalResponse, + const upsert = rs.spyOn(sdk.api.experience, 'upsertProfile') + const response = await createNextjsOptimizationContextHandler({ consent: true, sdk })( + new NextRequest('https://example.com/products'), ) - - expect(response).toBe(terminalResponse) - expect(consent).not.toHaveBeenCalled() - expect(upsertProfile).not.toHaveBeenCalled() - expect(response.headers.get('x-middleware-override-headers')).toBeNull() - expect(response.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() - expect( - response.headers.get(`x-middleware-request-${NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER}`), - ).toBeNull() - expect(response.headers.get('set-cookie')).toBeNull() - }) - - it('ignores the Next middleware/proxy event argument and returns a response', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const requestHandler = createNextjsOptimizationContextHandler() - const request = new NextRequest('https://example.com/products') - - const response = await requestHandler(request, createNextFetchEvent(request)) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - - expect(response).toBeInstanceOf(Response) - expect(forwardedHeaders?.get('x-ctfl-opt-request-url')).toBe('https://example.com/products') + expect(response.cookies.get('ctfl-opt-aid')).toBeUndefined() + expect(upsert).not.toHaveBeenCalled() }) - it('calls Experience once, forwards compact server context, and persists the returned profile ID', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') + it.each(['json', 'redirect'] as const)('preserves a terminal %s response', async (kind) => { const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: true }, - locale: 'en-US', - sdk, - }) - - const response = await requestHandler( - new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', - [NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER]: 'forged', - }, - }), - ) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - const context = parseNextjsOptimizationRequestContext( - forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) ?? null, + const forRequest = rs.spyOn(sdk, 'forRequest') + const consent = rs.fn(() => true) + const prior = + kind === 'json' + ? NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + : NextResponse.redirect('https://example.com/login') + const response = await createNextjsOptimizationContextHandler({ consent, sdk })( + new NextRequest('https://example.com/products'), + prior, ) - - expect(forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER)).not.toBe('forged') - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(upsertProfile).toHaveBeenCalledWith( - expect.objectContaining({ profileId: undefined }), - expect.objectContaining({ locale: 'en-US' }), - ) - expect(context).toEqual({ - consent: { events: true, persistence: true }, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - expect(response.cookies.get('ctfl-opt-aid')?.value).toBe('f0837d7dc6344c36a3a0a06c4cde754b') - }) - - it('does not forward oversized OptimizationData in the trusted server context header', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const sdk = configureNextjsServerOptimization(sdkConfig) - const largeTrait = 'x'.repeat(20_000) - const largeData: OptimizationData = { - ...optimizationData, - profile: { - ...optimizationData.profile, - traits: { largeTrait }, - }, - } - rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue(largeData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: true, - sdk, - }) - - await requestHandler(new NextRequest('https://example.com/products')) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - const header = forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) ?? null - const context = parseNextjsOptimizationRequestContext(header) - - expect(decodeURIComponent(header ?? '')).not.toContain(largeTrait) - expect(header?.length).toBeLessThan(300) - expect(context).toEqual({ - consent: true, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) + expect(response).toBe(prior) + expect(consent).not.toHaveBeenCalled() + expect(forRequest).not.toHaveBeenCalled() + expect(response.headers.get('x-middleware-override-headers')).toBeNull() }) - it('forwards blocked no-data server results with pageAccepted false', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const sdk = configureNextjsServerOptimization({ ...sdkConfig, allowedEventTypes: [] }) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockRejectedValue(new Error('blocked page should not call Experience')) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: false, - sdk, - }) - - await requestHandler(new NextRequest('https://example.com/products')) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - const context = parseNextjsOptimizationRequestContext( - forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) ?? null, + it('accepts the middleware event argument', async () => { + const request = new NextRequest('https://example.com/products') + const response = await createNextjsOptimizationContextHandler()( + request, + createNextFetchEvent(request), ) - - expect(upsertProfile).not.toHaveBeenCalled() - expect(context).toEqual({ - consent: false, - pageAccepted: false, - }) + expect(response.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe(request.url) }) - it('binds an incoming anonymous ID before persisting the returned profile ID', async () => { + it('uses prior cookie overrides for consent and identity', async () => { const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: true }, - sdk, + const forRequest = rs.spyOn(sdk, 'forRequest') + const consent: NextjsOptimizationServerConsentResolver = ({ cookies }) => + cookies.get('consent')?.value === 'yes' + const request = new NextRequest('https://example.com/products', { + headers: { cookie: 'consent=no; ctfl-opt-aid=old-id' }, }) - - const request = new NextRequest('https://example.com/products') - request.cookies.set('ctfl-opt-aid', 'incoming-profile') - - await requestHandler(request) - - expect(upsertProfile).toHaveBeenCalledWith( - expect.objectContaining({ profileId: 'incoming-profile' }), - undefined, + const headers = new Headers(request.headers) + headers.set('cookie', `consent=yes; ctfl-opt-aid=${profileId}`) + const prior = NextResponse.next({ request: { headers } }) + const response = await createNextjsOptimizationContextHandler({ consent, sdk })(request, prior) + expect(forRequest).toHaveBeenCalledWith( + expect.objectContaining({ consent: true, profile: { id: profileId } }), ) + expect(response.cookies.get('ctfl-opt-aid')?.value).toBe(profileId) + expect(response.headers.get('x-middleware-request-cookie')).toBe(headers.get('cookie')) }) - it('uses forwarded cookie overrides for consent and profile binding on an existing response', async () => { + it('respects a removed cookie header in prior request overrides', async () => { const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const seenConsentCookies: Array = [] - const consent: NextjsOptimizationServerConsentResolver = ({ cookies }) => { - const value = cookies.get('consent')?.value - seenConsentCookies.push(value) - return value === 'yes' ? { events: true, persistence: true } : false - } - const requestHandler = createNextjsOptimizationContextHandler({ consent, sdk }) + const forRequest = rs.spyOn(sdk, 'forRequest') const request = new NextRequest('https://example.com/products', { - headers: { - 'user-agent': 'test-agent', - }, + headers: { cookie: `ctfl-opt-aid=${profileId}` }, }) - request.cookies.set('ctfl-opt-aid', 'a19c3f54d2b84e37a93f6d1c0e5b7284') - request.cookies.set('consent', 'no') - const forwardedCookie = 'ctfl-opt-aid=f0837d7dc6344c36a3a0a06c4cde754b; consent=yes' - const forwardedHeaders = new Headers(request.headers) - forwardedHeaders.set('cookie', forwardedCookie) - const existingResponse = NextResponse.next({ request: { headers: forwardedHeaders } }) - existingResponse.headers.set('x-middleware-override-headers', 'cookie,user-agent') - existingResponse.headers.set('x-middleware-request-cookie', forwardedCookie) - existingResponse.headers.set('x-middleware-request-user-agent', 'test-agent') - - const response = await requestHandler(request, existingResponse) - const context = parseNextjsOptimizationRequestContext( - response.headers.get(`x-middleware-request-${NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER}`), + const prior = NextResponse.next({ request: { headers: new Headers({ 'user-agent': 'test' }) } }) + const response = await createNextjsOptimizationContextHandler({ consent: true, sdk })( + request, + prior, ) - - expect(response).toBe(existingResponse) - expect(seenConsentCookies).toEqual(['yes']) - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(upsertProfile).toHaveBeenCalledWith( - expect.objectContaining({ profileId: 'f0837d7dc6344c36a3a0a06c4cde754b' }), - undefined, - ) - expect(context).toEqual({ - consent: { events: true, persistence: true }, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - expect(response.cookies.get('ctfl-opt-aid')?.value).toBe('f0837d7dc6344c36a3a0a06c4cde754b') + expect(forRequest).toHaveBeenCalledWith(expect.objectContaining({ profile: undefined })) + expect(response.cookies.get('ctfl-opt-aid')).toBeUndefined() }) - it('clears the profile cookie when persistence is not allowed', async () => { + it('clears the cookie using configured scope when persistence is denied', async () => { const sdk = configureNextjsServerOptimization(sdkConfig) - rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ + const handler = createNextjsOptimizationContextHandler({ consent: { events: true, persistence: false }, sdk, + cookieOptions: { domain: 'example.com', path: '/products', secure: true, sameSite: 'strict' }, + }) + const response = await handler( + new NextRequest('https://example.com/products', { + headers: { cookie: `ctfl-opt-aid=${profileId}` }, + }), + ) + expect(response.cookies.get('ctfl-opt-aid')).toMatchObject({ + value: '', + domain: 'example.com', + path: '/products', + maxAge: 0, + secure: true, + sameSite: 'strict', }) - - const response = await requestHandler(new NextRequest('https://example.com/products')) - - expect(response.headers.get('set-cookie')).toContain('ctfl-opt-aid=') expect(response.headers.get('set-cookie')).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT') }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/request-handler.ts b/packages/web/frameworks/nextjs-sdk/src/request-handler.ts index c51679968..92aab9ff8 100644 --- a/packages/web/frameworks/nextjs-sdk/src/request-handler.ts +++ b/packages/web/frameworks/nextjs-sdk/src/request-handler.ts @@ -1,5 +1,9 @@ import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server' -import { createCookieReaderFromHeader } from './cookies' +import { + createCookieReaderFromHeader, + DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, + persistNextjsAnonymousId, +} from './cookies' import { applyForwardedRequestHeaders, clearForwardedRequestHeaders, @@ -9,20 +13,16 @@ import { import { NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX, NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext, + normalizeNextjsAppRequestUrl, } from './request-context' -import { - getNextjsServerOptimizationData, - persistNextjsAnonymousId, - type ContentfulOptimization, - type CoreStatelessRequest, - type CoreStatelessRequestConsent, - type NextjsAnonymousIdCookieOptions, - type NextjsCookieReader, - type NextjsOptimizationServerConsentResolver, - type OptimizationData, - type PersistNextjsAnonymousIdOptions, +import type { + ContentfulOptimization, + CoreStatelessRequest, + CoreStatelessRequestConsent, + NextjsAnonymousIdCookieOptions, + NextjsCookieReader, + NextjsOptimizationServerConsentResolver, + PersistNextjsAnonymousIdOptions, } from './server' export type MaybePromise = T | Promise @@ -56,83 +56,55 @@ export function createNextjsOptimizationContextHandler( if (response && hasExistingTerminalMiddlewareTarget(response)) return response const requestHeaders = createSanitizedForwardedRequestHeaders(request, response) - const result = + const requestOptimization = options === undefined ? undefined - : await getRequestOptimizationData( + : await bindRequestIdentity( request, requestHeaders, hasRequestHeaderOverrides(response), options, ) - - if (result !== undefined) { - requestHeaders.set( - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext({ - consent: result.consent, - pageAccepted: result.pageAccepted, - profileId: result.profileId, - }), - ) - } + const cookieOptions = + options === undefined + ? undefined + : { + ...options, + cookieOptions: { ...options.cookieOptions, httpOnly: false }, + } if (!response) { const nextResponse = NextResponse.next({ request: { headers: requestHeaders } }) - if (options !== undefined && result !== undefined) { - persistNextjsAnonymousId(nextResponse, result.requestOptimization, result.data, options) + if (cookieOptions !== undefined && requestOptimization !== undefined) { + persistNextjsAnonymousId(nextResponse, requestOptimization, undefined, cookieOptions) } return nextResponse } applyNextjsOptimizationRequestContext(response, requestHeaders) - if (options !== undefined && result !== undefined) { - persistNextjsAnonymousId(response, result.requestOptimization, result.data, options) + if (cookieOptions !== undefined && requestOptimization !== undefined) { + persistNextjsAnonymousId(response, requestOptimization, undefined, cookieOptions) } return response } } -interface RequestOptimizationData { - readonly consent: CoreStatelessRequestConsent - readonly data: OptimizationData | undefined - readonly pageAccepted: boolean - readonly profileId: string | undefined - readonly requestOptimization: CoreStatelessRequest -} - -async function getRequestOptimizationData( +async function bindRequestIdentity( request: NextRequest, headers: Headers, requestHeaderOverrides: boolean, options: NextjsOptimizationContextHandlerOptions, -): Promise { +): Promise { const cookies = createEffectiveRequestCookies(request, headers, requestHeaderOverrides) - const consent = await resolveServerConsent(options.consent, { - cookies, - headers, - }) - const { data, pageResult, requestOptimization } = await getNextjsServerOptimizationData( - options.sdk, - { - anonymousIdCookieName: options.anonymousIdCookieName, - consent, - locale: options.locale, - request: { - cookies, - headers, - url: request.url, - }, - }, - ) - - return { + const consent = await resolveServerConsent(options.consent, { cookies, headers }) + const profileId = cookies.get( + options.anonymousIdCookieName ?? DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, + )?.value + return options.sdk.forRequest({ consent, - data, - pageAccepted: pageResult.accepted, - profileId: data?.profile.id ?? requestOptimization.profile?.id, - requestOptimization, - } + profile: profileId ? { id: profileId } : undefined, + locale: options.locale, + }) } function createEffectiveRequestCookies( @@ -212,5 +184,8 @@ function sanitizeForwardedRequestHeaders(requestHeaders: Headers, requestUrl: st } } - requestHeaders.set(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, requestUrl) + requestHeaders.set( + NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, + normalizeNextjsAppRequestUrl(requestUrl), + ) } diff --git a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts index 26b241942..be269e319 100644 --- a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts @@ -205,7 +205,6 @@ export function acceptAppRouterRequestConfig( request: { hydration: ({ requestUrl, routeKey }) => requestUrl.includes(routeKey) ? 'preserve-server' : 'client-only-hidden-until-ready', - trustedRequestHandoff: true, }, } void resolvedConfig @@ -216,7 +215,6 @@ export function acceptBoundPublicPermutationHandoffOverload( ): ContentOptimizationHandoff { return components.createPublicPermutationHandoff({ hydration: 'preserve-server', - initialPageEvent: 'emit', permutationKey: 'new-visitor', selectedOptimizations: [], }) @@ -268,8 +266,8 @@ export function rejectAppRouterRequestOwnedProps( hydration: 'preserve-server', }) void components.request.NextAppAutoPageTracker({ - // @ts-expect-error Request page trackers own the initial page event. - initialPageEvent: 'emit', + // @ts-expect-error Request page trackers own the prepared handoff. + handoff, }) } @@ -285,13 +283,12 @@ export function acceptAppRouterRootPageEventProps( }) } -export function rejectAppRouterProviderRouteKey( +export function acceptAppRouterProviderRouteKey( components: NextjsAppClientComponents, props: BoundNextjsOptimizationProviderProps, ): void { components.OptimizationProvider({ ...props, - // @ts-expect-error Bound provider does not own page route wiring. routeKey: '/products', }) } diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index 29a3c52ba..14e73c520 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -10,6 +10,7 @@ import type { CoreStatelessRequestOptions, EventEmissionResult, FetchOptimizedEntryResult, + InitialExperienceEvent, ManagedEntryHandoff, PageViewBuilderArgs, PrivateRequestOptimizationCacheMetadata, @@ -19,11 +20,7 @@ import { createPageContextFromUrl } from '@contentful/optimization-node/core-sdk import type { ChainModifiers, EntrySkeletonType, LocaleCode } from 'contentful' import type { JSX, ReactElement, ReactNode } from 'react' import type { NextjsCookieReader } from './bound-component-types' -import { - DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, - type NextjsAnonymousIdCookieOptions, - type PersistNextjsAnonymousIdOptions, -} from './cookies' +import { DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, type NextjsAnonymousIdCookieOptions } from './cookies' import type { AnalyticsOptimizationHandoff, BrowserOptimizationHandoff, @@ -68,6 +65,7 @@ export type { } from './bound-component-types' export { DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, + persistNextjsAnonymousId, type NextjsAnonymousIdCookieOptions, type PersistNextjsAnonymousIdOptions, } from './cookies' @@ -126,10 +124,13 @@ export interface NextjsRequestHandoffOptions extends NextjsServerOptimizationDat readonly cache?: PrivateRequestOptimizationCacheMetadata readonly entries?: readonly ManagedEntryHandoff[] readonly hydration: OptimizationHydrationMode - readonly pagePayload: PageViewBuilderArgs + readonly initialEvents?: readonly InitialExperienceEvent[] } -export interface NextjsRequestHandoffResult extends NextjsServerOptimizationData { +export interface NextjsRequestHandoffResult extends Omit< + NextjsServerOptimizationData, + 'pageResult' +> { readonly handoff: BrowserOptimizationHandoff } @@ -367,11 +368,19 @@ export async function getNextjsServerOptimizationData( export function createNextjsRequestHandoff( sdk: ContentfulOptimization, options: NextjsRequestHandoffOptions & { readonly hydration: 'analytics-only' }, -): Promise +): Promise< + Omit & { + readonly handoff: AnalyticsOptimizationHandoff + } +> export function createNextjsRequestHandoff( sdk: ContentfulOptimization, options: NextjsRequestHandoffOptions & { readonly hydration: ContentOptimizationHydrationMode }, -): Promise +): Promise< + Omit & { + readonly handoff: ContentOptimizationHandoff + } +> export function createNextjsRequestHandoff( sdk: ContentfulOptimization, options: NextjsRequestHandoffOptions, @@ -380,56 +389,26 @@ export async function createNextjsRequestHandoff( sdk: ContentfulOptimization, options: NextjsRequestHandoffOptions, ): Promise { - const { cache, entries, hydration, ...requestOptions } = options - const { data, pageResult, requestOptimization } = await getNextjsServerOptimizationData( - sdk, - requestOptions, - ) + const { cache, entries, hydration, initialEvents, ...requestOptions } = options + const requestOptimization = bindNextjsOptimizationRequest(sdk, requestOptions) + const requestUrl = + options.request?.url ?? getRequestHeaders(options)?.get(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER) + const routePage = requestUrl + ? createPageContextFromUrl(requestUrl) + : getExplicitPage(options.page) + const { handoff: prepared, data } = await requestOptimization.prepareRequestHandoff({ + routeKey: `${routePage?.path ?? ''}${routePage?.search ?? ''}`, + initialEvents, + page: options.pagePayload, + }) const handoff = addBrowserHandoffMetadata( - createRequestHandoffFromData({ - ...(cache === undefined ? {} : { cache }), - data, - ...(entries === undefined ? {} : { entries }), - }), { - hydration, - initialPageEvent: pageResult.accepted ? 'skip' : 'emit', + ...prepared, + ...createRequestHandoffFromData({ cache, data, entries }), }, + { hydration }, ) - - return { data, handoff, pageResult, requestOptimization } -} - -export function persistNextjsAnonymousId( - response: NextjsResponseLike, - requestOptimization: CoreStatelessRequest, - data: OptimizationData | undefined, - { - anonymousIdCookieName = DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, - cookieOptions, - deleteWhenProfileCannotPersist = true, - }: PersistNextjsAnonymousIdOptions = {}, -): void { - const profileId = data?.profile.id ?? requestOptimization.profile?.id - - if (requestOptimization.canPersistProfile && profileId) { - response.cookies.set(anonymousIdCookieName, profileId, { - path: '/', - sameSite: 'lax', - ...cookieOptions, - }) - return - } - - if (deleteWhenProfileCannotPersist) { - response.cookies.set(anonymousIdCookieName, '', { - path: '/', - sameSite: 'lax', - ...cookieOptions, - expires: new Date(0), - maxAge: 0, - }) - } + return { data, handoff, requestOptimization } } function getServerOptimizedEntryData< From da700f8f1041555475f4bc9b64f3f63e65622c52 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Wed, 7 Oct 2026 14:38:42 +0200 Subject: [PATCH 07/29] chore(repo): update SDK development harnesses for paired replay --- packages/node/node-sdk/dev/index.ejs | 32 ++++ packages/node/node-sdk/dev/server.ts | 40 ++++- packages/react-native-sdk/dev/.env.example | 18 +-- packages/react-native-sdk/dev/App.tsx | 55 ++++--- .../dev/android/app/build.gradle | 14 ++ .../react-native-sdk/dev/android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../dev/android/settings.gradle | 13 +- .../react-native-sdk/dev/babel.config.cjs | 37 +++++ packages/react-native-sdk/dev/babel.config.js | 8 - packages/react-native-sdk/dev/env.config.ts | 19 ++- .../dev/{metro.config.js => metro.config.cjs} | 41 ++--- .../dev/react-native.config.js | 4 - .../dev/scripts/run-dev-dashboard.sh | 140 ++++++++---------- .../react-native-sdk/dev/utils/sdkHelpers.ts | 43 ------ packages/react-native-sdk/package.json | 8 +- .../react-native-sdk/react-native.config.cjs | 7 + .../frameworks/react-web-sdk/dev/app/App.tsx | 22 +-- .../frameworks/react-web-sdk/dev/app/main.tsx | 80 ++++++++-- .../dev/app/sections/LifecycleSection.tsx | 5 + .../dev/app/sections/ProvidersSection.tsx | 25 +--- .../react-web-sdk/dev/rsbuild.config.ts | 6 + .../scripts/launch-dev-harness.sh | 31 ++-- packages/web/web-sdk/dev/index.html | 26 +++- packages/web/web-sdk/dev/rsbuild.config.ts | 6 + 25 files changed, 410 insertions(+), 274 deletions(-) create mode 100644 packages/react-native-sdk/dev/babel.config.cjs delete mode 100644 packages/react-native-sdk/dev/babel.config.js rename packages/react-native-sdk/dev/{metro.config.js => metro.config.cjs} (52%) delete mode 100644 packages/react-native-sdk/dev/react-native.config.js create mode 100644 packages/react-native-sdk/react-native.config.cjs diff --git a/packages/node/node-sdk/dev/index.ejs b/packages/node/node-sdk/dev/index.ejs index 450effa19..8802480ba 100644 --- a/packages/node/node-sdk/dev/index.ejs +++ b/packages/node/node-sdk/dev/index.ejs @@ -90,6 +90,7 @@

States

+ <% if (profile) { %> <% if (consent) { %> Reject Consent <% } else { %> @@ -102,6 +103,9 @@ Identify <% } %> | + <% } else { %> + Profile unavailable; baseline content is shown where available. + <% } %>
<%= JSON.stringify(profile, undefined, 2) %>
@@ -145,6 +149,7 @@
<% const entry1 = entries.get('1MwiFl4z7gkwqGYdvCmr8c') %> + <% if (entry1) { %>
<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %> <% const entry2 = entries.get('4ib0hsHWoSOnCVdDkizE8d') %> + <% if (entry2) { %>
<%= entry2.entry.fields.text %>

<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %> <% const entry3 = entries.get('xFwgG3oNaOcjzWiGe4vXo') %> + <% if (entry3) { %>
<%= entry3.entry.fields.text %>

<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %> <% const entry4 = entries.get('2Z2WLOx07InSewC3LUB3eX') %> + <% if (entry4) { %>
<%= entry4.entry.fields.text %>

<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %> <% const entry5 = entries.get('5XHssysWUDECHzKLzoIsg1') %> + <% if (entry5) { %>
<%= entry5.entry.fields.text %>

<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %> <% const entry6 = entries.get('6zqoWXyiSrf0ja7I2WGtYj') %> + <% if (entry6) { %>
<%= entry6.entry.fields.text %>

<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %> <% const entry7 = entries.get('7pa5bOx8Z9NmNcr7mISvD') %> + <% if (entry7) { %>
<%= entry7.entry.fields.text %>

<% } %>
+ <% } else { %> +

Content entry unavailable.

+ <% } %>
diff --git a/packages/node/node-sdk/dev/server.ts b/packages/node/node-sdk/dev/server.ts index 5f1eacb87..bbb8beea0 100644 --- a/packages/node/node-sdk/dev/server.ts +++ b/packages/node/node-sdk/dev/server.ts @@ -6,15 +6,19 @@ import express, { type Express } from 'express' import rateLimit from 'express-rate-limit' import path from 'node:path' import { fileURLToPath } from 'node:url' -import ContentfulOptimization from '../src' +import ContentfulOptimization, { createRequestHandoffFromData } from '../src' import type { PartialProfile, Profile, SelectedOptimization } from '../src/api-schemas' import { isMergeTagEntry, isRichTextDocument } from '../src/api-schemas' +import { ANONYMOUS_ID_COOKIE, createPageContextFromUrl } from '../src/core-sdk' /* eslint-disable @typescript-eslint/naming-convention -- standardized var names */ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) /* eslint-enable @typescript-eslint/naming-convention -- standardized var names */ +const BAD_REQUEST_STATUS = 400 +const DEFAULT_DEV_PORT = 3001 + const limiter = rateLimit({ windowMs: 30_000, max: 1000, @@ -209,6 +213,38 @@ async function getRequestOptimizationData({ } } +// Shared preview input for the Web and React dev harnesses. Browser delivery uses the SDK queue. +app.get('/__handoff', limiter, async (req, res) => { + if (typeof req.query.url !== 'string') { + res.status(BAD_REQUEST_STATUS).json({ error: 'Supply the visible page URL.' }) + return + } + const page = createPageContextFromUrl(req.query.url) + const cookie = req.headers.cookie + ?.split(';') + .find((part) => part.trim().startsWith(`${ANONYMOUS_ID_COOKIE}=`)) + const profileId = cookie?.trim().slice(ANONYMOUS_ID_COOKIE.length + 1) + const request = sdk.forRequest({ + consent: true, + profile: profileId ? { id: profileId } : undefined, + eventContext: { page, userAgent: req.get('user-agent') }, + }) + const { handoff, data } = await request.prepareRequestHandoff({ + routeKey: `${page.path}${page.search}`, + page: { properties: page }, + initialEvents: [ + { type: 'identify', userId: 'paired-dev-visitor', traits: { continent: 'EU' } }, + ], + }) + if (handoff.profileId) + res.cookie(ANONYMOUS_ID_COOKIE, handoff.profileId, { path: '/', sameSite: 'lax' }) + res.set('Cache-Control', 'no-store').json({ + ...handoff, + ...createRequestHandoffFromData({ data }), + hydration: 'preserve-server', + }) +}) + app.get('/', limiter, async (req, res) => { const profileId = applyRequestState(req.query) const { consent, userId } = profileId ? (profileState.get(profileId) ?? {}) : {} @@ -278,7 +314,7 @@ app.get('/', limiter, async (req, res) => { res.render('index', { ...pageData }) }) -const port = 3000 +const port = Number(process.env.PORT ?? DEFAULT_DEV_PORT) app.listen(port, () => { // eslint-disable-next-line no-console -- debug diff --git a/packages/react-native-sdk/dev/.env.example b/packages/react-native-sdk/dev/.env.example index 8c4ef6854..c04d78c5e 100644 --- a/packages/react-native-sdk/dev/.env.example +++ b/packages/react-native-sdk/dev/.env.example @@ -1,9 +1,9 @@ -VITE_CONTENTFUL_TOKEN= -VITE_CONTENTFUL_PREVIEW_TOKEN= -VITE_CONTENTFUL_ENVIRONMENT= -VITE_CONTENTFUL_SPACE_ID= - -VITE_EXPERIENCE_API_BASE_URL= -VITE_INSIGHTS_API_BASE_URL= -VITE_CONTENTFUL_CDA_HOST= -VITE_CONTENTFUL_BASE_PATH= +MOCK_SERVER_PORT=8000 +PUBLIC_CONTENTFUL_TOKEN=test-token +PUBLIC_CONTENTFUL_ENVIRONMENT=master +PUBLIC_CONTENTFUL_SPACE_ID=test-space +PUBLIC_CONTENTFUL_BASE_PATH=/contentful/ +# Optional endpoint overrides; otherwise use localhost and MOCK_SERVER_PORT. +# PUBLIC_EXPERIENCE_API_BASE_URL= +# PUBLIC_INSIGHTS_API_BASE_URL= +# PUBLIC_CONTENTFUL_CDA_HOST= diff --git a/packages/react-native-sdk/dev/App.tsx b/packages/react-native-sdk/dev/App.tsx index 77d402392..97e1ff6e8 100644 --- a/packages/react-native-sdk/dev/App.tsx +++ b/packages/react-native-sdk/dev/App.tsx @@ -17,8 +17,7 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' -import type { ContentfulOptimization } from '@contentful/optimization-react-native' -import { OptimizationRoot } from '@contentful/optimization-react-native' +import { OptimizationRoot, useOptimization } from '@contentful/optimization-react-native' import { isMergeTagEntry, isRecord, @@ -44,7 +43,6 @@ import { fetchDemoEntries, fetchEntriesFromMockServer, fetchMergeTagEntry, - initializeSDK, } from './utils/sdkHelpers' type ScreenType = 'home' | 'tracking' | 'optimization' @@ -112,14 +110,19 @@ const contentfulClient = createClient({ }) // eslint-disable-next-line complexity -- Main app component requires conditional rendering and state management logic -function App(): React.JSX.Element { +function Dashboard(): React.JSX.Element { const isDarkMode = useColorScheme() === 'dark' const colors = getThemeColors(isDarkMode) - const [sdkLoaded, setSdkLoaded] = useState(false) + const sdk = useOptimization() + const sdkLoaded = true const [sdkError, setSdkError] = useState(null) - const [sdkInfo, setSdkInfo] = useState(null) - const [sdk, setSdk] = useState(null) + const [sdkInfo] = useState(() => ({ + spaceId: ENV_CONFIG.optimization.spaceId, + environment: ENV_CONFIG.optimization.environment, + initialized: true, + timestamp: new Date().toISOString(), + })) const [currentScreen, setCurrentScreen] = useState('home') const [optimizedEntry, setOptimizedEntry] = useState(null) const [productEntry, setProductEntry] = useState(null) @@ -133,7 +136,6 @@ function App(): React.JSX.Element { useEffect(() => { async function initialize(): Promise { - await initializeSDK(setSdkInfo, setSdk, setSdkLoaded, setSdkError) try { await fetchMergeTagEntry(setMergeTagEntry) } catch (error) { @@ -146,8 +148,6 @@ function App(): React.JSX.Element { }, []) useEffect(() => { - if (!sdk) return - // Demonstrate direct screen tracking via sdk.screen() // This sends a "screen" event to track this screen view void sdk.screen({ name: 'Home', properties: { source: 'dev-app-direct' } }) @@ -162,7 +162,7 @@ function App(): React.JSX.Element { }, [sdk]) useEffect(() => { - if (!mergeTagEntry || !profile || !sdk) return + if (!mergeTagEntry || !profile) return const { fields } = mergeTagEntry const richTextField = Object.values(fields).find(isRichTextDocument) @@ -243,12 +243,12 @@ function App(): React.JSX.Element { setCurrentScreen('home') } - if (currentScreen === 'optimization' && sdk && demoEntries) { + if (currentScreen === 'optimization' && demoEntries) { return ( - + <> - + ) } @@ -256,9 +256,9 @@ function App(): React.JSX.Element { return } - if (currentScreen === 'tracking' && sdk && optimizedEntry && productEntry) { + if (currentScreen === 'tracking' && optimizedEntry && productEntry) { return ( - + <> - + ) } @@ -275,12 +275,8 @@ function App(): React.JSX.Element { return } - if (!sdk) { - return - } - return ( - + <> @@ -295,7 +291,7 @@ function App(): React.JSX.Element { - {sdkInfo && } + {mergeTagDetails.length > 0 && ( + + ) +} + +function App(): React.JSX.Element { + return ( + + ) } diff --git a/packages/react-native-sdk/dev/android/app/build.gradle b/packages/react-native-sdk/dev/android/app/build.gradle index 52366799d..63f59f4d0 100644 --- a/packages/react-native-sdk/dev/android/app/build.gradle +++ b/packages/react-native-sdk/dev/android/app/build.gradle @@ -6,7 +6,21 @@ apply plugin: "com.facebook.react" * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. */ +def resolveReactNativePackage = { name -> + def packagePath = providers.exec { + workingDir file("../../..") + commandLine "node", "--print", "require.resolve('${name}/package.json', {paths: [require.resolve('react-native/package.json')]})" + }.standardOutput.asText.get().trim() + new File(packagePath).parentFile +} + react { + root = file("../../..") + reactNativeDir = resolveReactNativePackage("react-native") + codegenDir = resolveReactNativePackage("@react-native/codegen") + cliFile = new File(reactNativeDir.get().asFile, "cli.js") + entryFile = file("../../index.js") + bundleConfig = file("../../metro.config.cjs") /* Folders */ // The root of your project, i.e. where "package.json" lives. Default is '../..' // root = file("../../") diff --git a/packages/react-native-sdk/dev/android/build.gradle b/packages/react-native-sdk/dev/android/build.gradle index b82cffaaa..468330f0b 100644 --- a/packages/react-native-sdk/dev/android/build.gradle +++ b/packages/react-native-sdk/dev/android/build.gradle @@ -5,7 +5,7 @@ buildscript { compileSdkVersion = 36 targetSdkVersion = 36 ndkVersion = "27.1.12297006" - kotlinVersion = "2.2.21" + kotlinVersion = "2.1.20" } repositories { google() diff --git a/packages/react-native-sdk/dev/android/gradle/wrapper/gradle-wrapper.properties b/packages/react-native-sdk/dev/android/gradle/wrapper/gradle-wrapper.properties index c61a118f7..2a84e188b 100644 --- a/packages/react-native-sdk/dev/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/react-native-sdk/dev/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/packages/react-native-sdk/dev/android/settings.gradle b/packages/react-native-sdk/dev/android/settings.gradle index fdd7eb542..203780ef0 100644 --- a/packages/react-native-sdk/dev/android/settings.gradle +++ b/packages/react-native-sdk/dev/android/settings.gradle @@ -1,6 +1,13 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { + def pluginPackage = providers.exec { + workingDir file("../..") + commandLine "node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', {paths: [require.resolve('react-native/package.json')]})" + }.standardOutput.asText.get().trim() + gradle.ext.reactNativeGradlePluginDir = new File(pluginPackage).parentFile + includeBuild(gradle.ext.reactNativeGradlePluginDir.path) +} plugins { id("com.facebook.react.settings") } -extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand(["pnpm", "exec", "bash", "-c", "cd dev && react-native config"], file("../..")) } rootProject.name = 'ReactNativeApp' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild(gradle.ext.reactNativeGradlePluginDir.path) diff --git a/packages/react-native-sdk/dev/babel.config.cjs b/packages/react-native-sdk/dev/babel.config.cjs new file mode 100644 index 000000000..58e75d563 --- /dev/null +++ b/packages/react-native-sdk/dev/babel.config.cjs @@ -0,0 +1,37 @@ +const { existsSync } = require('node:fs') +const path = require('node:path') + +const envFile = path.join(__dirname, '.env') +if (existsSync(envFile)) process.loadEnvFile(envFile) + +// Resolve dev settings at build time; no environment loader ships in the native app. +function inlineDevEnvironment({ types }) { + return { + visitor: { + MemberExpression(expression) { + const { node } = expression + if ( + !expression.get('object').matchesPattern('process.env') || + node.computed || + !types.isIdentifier(node.property) || + (!node.property.name.startsWith('PUBLIC_') && node.property.name !== 'MOCK_SERVER_PORT') + ) + return + const value = process.env[node.property.name] + expression.replaceWith( + value === undefined ? types.identifier('undefined') : types.stringLiteral(value), + ) + }, + }, + } +} + +module.exports = { + presets: ['module:@react-native/babel-preset'], + plugins: [ + inlineDevEnvironment, + ['@babel/plugin-proposal-decorators', { version: '2023-05' }], + '@babel/plugin-transform-class-static-block', + '@babel/plugin-transform-export-namespace-from', + ], +} diff --git a/packages/react-native-sdk/dev/babel.config.js b/packages/react-native-sdk/dev/babel.config.js deleted file mode 100644 index d437dada8..000000000 --- a/packages/react-native-sdk/dev/babel.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - presets: ['module:@react-native/babel-preset'], - plugins: [ - ['@babel/plugin-proposal-decorators', { version: '2023-05' }], - '@babel/plugin-transform-class-static-block', - '@babel/plugin-transform-export-namespace-from', - ], -} diff --git a/packages/react-native-sdk/dev/env.config.ts b/packages/react-native-sdk/dev/env.config.ts index a78ee60cb..64c48cda1 100644 --- a/packages/react-native-sdk/dev/env.config.ts +++ b/packages/react-native-sdk/dev/env.config.ts @@ -7,13 +7,18 @@ import { Platform } from 'react-native' -const PUBLIC_EXPERIENCE_API_BASE_URL = 'http://localhost:8000/experience/' -const PUBLIC_INSIGHTS_API_BASE_URL = 'http://localhost:8000/insights/' -const PUBLIC_CONTENTFUL_TOKEN = 'test-token' -const PUBLIC_CONTENTFUL_ENVIRONMENT = 'master' -const PUBLIC_CONTENTFUL_SPACE_ID = 'test-space' -const PUBLIC_CONTENTFUL_CDA_HOST = 'localhost:8000' -const PUBLIC_CONTENTFUL_BASE_PATH = '/contentful/' +const mockServerPort = process.env.MOCK_SERVER_PORT ?? '8000' + +const PUBLIC_EXPERIENCE_API_BASE_URL = + process.env.PUBLIC_EXPERIENCE_API_BASE_URL ?? `http://localhost:${mockServerPort}/experience/` +const PUBLIC_INSIGHTS_API_BASE_URL = + process.env.PUBLIC_INSIGHTS_API_BASE_URL ?? `http://localhost:${mockServerPort}/insights/` +const PUBLIC_CONTENTFUL_TOKEN = process.env.PUBLIC_CONTENTFUL_TOKEN ?? 'test-token' +const PUBLIC_CONTENTFUL_ENVIRONMENT = process.env.PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master' +const PUBLIC_CONTENTFUL_SPACE_ID = process.env.PUBLIC_CONTENTFUL_SPACE_ID ?? 'test-space' +const PUBLIC_CONTENTFUL_CDA_HOST = + process.env.PUBLIC_CONTENTFUL_CDA_HOST ?? `localhost:${mockServerPort}` +const PUBLIC_CONTENTFUL_BASE_PATH = process.env.PUBLIC_CONTENTFUL_BASE_PATH ?? '/contentful/' interface EnvConfig { contentful: { diff --git a/packages/react-native-sdk/dev/metro.config.js b/packages/react-native-sdk/dev/metro.config.cjs similarity index 52% rename from packages/react-native-sdk/dev/metro.config.js rename to packages/react-native-sdk/dev/metro.config.cjs index f7fa8f121..245e7f746 100644 --- a/packages/react-native-sdk/dev/metro.config.js +++ b/packages/react-native-sdk/dev/metro.config.cjs @@ -8,8 +8,8 @@ const path = require('path') * @type {import('metro-config').MetroConfig} */ -// Get the workspace root (four levels up from this file) -const workspaceRoot = path.resolve(__dirname, '../../../../') +// Get the workspace root (three levels up from this file) +const workspaceRoot = path.resolve(__dirname, '../../../') // Get the React Native package directory (one level up from dev) const reactNativePackageDir = path.resolve(__dirname, '..') @@ -27,33 +27,18 @@ const config = { resolveRequest: (context, moduleName, platform) => { // Resolve workspace packages to their source files instead of dist const workspacePackages = { - '@contentful/optimization-core': path.resolve( - workspaceRoot, - 'packages/universal/core-sdk/src/index.ts', - ), - '@contentful/optimization-core/preview-support': path.resolve( - workspaceRoot, - 'packages/universal/core-sdk/src/preview-support/index.ts', - ), - '@contentful/optimization-api-client': path.resolve( - workspaceRoot, - 'packages/universal/api-client/src/index.ts', - ), - '@contentful/optimization-api-schemas': path.resolve( - workspaceRoot, - 'packages/universal/api-schemas/src/index.ts', - ), - '@contentful/optimization-react-native': path.resolve( - workspaceRoot, - 'packages/react-native-sdk/src/index.ts', - ), + '@contentful/optimization-core': 'packages/universal/core-sdk/src', + '@contentful/optimization-api-client': 'packages/universal/api-client/src', + '@contentful/optimization-api-schemas': 'packages/universal/api-schemas/src', + '@contentful/optimization-react-native': 'packages/react-native-sdk/src', } - - if (workspacePackages[moduleName]) { - return { - filePath: workspacePackages[moduleName], - type: 'sourceFile', - } + const packageName = Object.keys(workspacePackages).find( + (name) => moduleName === name || moduleName.startsWith(`${name}/`), + ) + if (packageName) { + const subpath = moduleName.slice(packageName.length).replace(/^\//, '') || 'index' + const source = path.resolve(workspaceRoot, workspacePackages[packageName], subpath) + return context.resolveRequest(context, source, platform) } // Let Metro handle everything else diff --git a/packages/react-native-sdk/dev/react-native.config.js b/packages/react-native-sdk/dev/react-native.config.js deleted file mode 100644 index fda1377e1..000000000 --- a/packages/react-native-sdk/dev/react-native.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - // Point React Native CLI to the parent directory where package.json with dependencies lives - root: '..', -} diff --git a/packages/react-native-sdk/dev/scripts/run-dev-dashboard.sh b/packages/react-native-sdk/dev/scripts/run-dev-dashboard.sh index c82a346d0..156b5d18b 100755 --- a/packages/react-native-sdk/dev/scripts/run-dev-dashboard.sh +++ b/packages/react-native-sdk/dev/scripts/run-dev-dashboard.sh @@ -21,7 +21,7 @@ # ./scripts/run-dev-dashboard.sh --clean # Clean build first # # Prerequisites: -# - Node.js >= 18 +# - Node.js version from .nvmrc # - pnpm # - Watchman (recommended) # - For iOS: Xcode with iOS simulator, CocoaPods @@ -42,7 +42,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEV_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" RN_PACKAGE_DIR="$(cd "$DEV_DIR/.." && pwd)" -ROOT_DIR="$(cd "$RN_PACKAGE_DIR/../../.." && pwd)" +ROOT_DIR="$(cd "$RN_PACKAGE_DIR/../.." && pwd)" LOG_DIR="${DEV_DIR}/logs" MOCK_SERVER_LOG="${LOG_DIR}/mock-server.log" @@ -94,7 +94,7 @@ Examples: $(basename "$0") --clean --ios # Clean iOS build and run Prerequisites: - - Node.js >= 18 + - Node.js version from .nvmrc - pnpm - Watchman (brew install watchman) - For iOS: Xcode with iOS simulator, CocoaPods @@ -121,38 +121,25 @@ log_step() { echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" } +stop_process_tree() { + local pid="$1" + local child + for child in $(pgrep -P "$pid" || true); do + stop_process_tree "$child" + done + kill "$pid" 2>/dev/null || true +} + cleanup() { - log_info "Cleaning up background processes..." - - # Stop Metro bundler and its children + log_info "Stopping only the processes started by this launcher..." if [[ -n "$METRO_PID" ]]; then - log_info "Stopping Metro bundler (PID: $METRO_PID) and its process group..." - pkill -P "$METRO_PID" 2>/dev/null || true - kill "$METRO_PID" 2>/dev/null || true + stop_process_tree "$METRO_PID" wait "$METRO_PID" 2>/dev/null || true fi - - # Kill any remaining processes on Metro port - if lsof -ti:"${METRO_PORT}" > /dev/null 2>&1; then - log_info "Killing any remaining processes on Metro port ${METRO_PORT}..." - lsof -ti:"${METRO_PORT}" | xargs kill -9 2>/dev/null || true - fi - - # Stop mock server and its children if [[ -n "$MOCK_SERVER_PID" ]]; then - log_info "Stopping mock server (PID: $MOCK_SERVER_PID) and its process group..." - pkill -P "$MOCK_SERVER_PID" 2>/dev/null || true - kill "$MOCK_SERVER_PID" 2>/dev/null || true + stop_process_tree "$MOCK_SERVER_PID" wait "$MOCK_SERVER_PID" 2>/dev/null || true fi - - # Kill any remaining processes on mock server port - if lsof -ti:"${MOCK_SERVER_PORT}" > /dev/null 2>&1; then - log_info "Killing any remaining processes on mock server port ${MOCK_SERVER_PORT}..." - lsof -ti:"${MOCK_SERVER_PORT}" | xargs kill -9 2>/dev/null || true - fi - - log_info "Cleanup complete" } trap cleanup EXIT INT TERM @@ -173,7 +160,7 @@ wait_for_port() { echo -n "." sleep 1 - ((attempt++)) + attempt=$((attempt + 1)) done echo "" @@ -184,16 +171,12 @@ wait_for_port() { create_env_file() { log_step "Creating .env File" - cat > "${DEV_DIR}/.env" << EOF -PUBLIC_EXPERIENCE_API_BASE_URL=http://localhost:${MOCK_SERVER_PORT}/experience/ -PUBLIC_INSIGHTS_API_BASE_URL=http://localhost:${MOCK_SERVER_PORT}/insights/ -PUBLIC_CONTENTFUL_TOKEN=${PUBLIC_CONTENTFUL_TOKEN:-test-token} -PUBLIC_CONTENTFUL_ENVIRONMENT=${PUBLIC_CONTENTFUL_ENVIRONMENT:-master} -PUBLIC_CONTENTFUL_SPACE_ID=${PUBLIC_CONTENTFUL_SPACE_ID:-test-space} -PUBLIC_CONTENTFUL_CDA_HOST=localhost:${MOCK_SERVER_PORT} -PUBLIC_CONTENTFUL_BASE_PATH=/contentful/ -EOF - + if [[ -f "${DEV_DIR}/.env" ]]; then + log_info "Keeping the existing .env" + return 0 + fi + cp "${DEV_DIR}/.env.example" "${DEV_DIR}/.env" + log_info ".env file created at ${DEV_DIR}/.env" } @@ -201,6 +184,10 @@ install_dependencies() { log_step "Installing Dependencies" cd "$ROOT_DIR" + if [[ -d "${ROOT_DIR}/node_modules" ]]; then + log_info "Workspace dependencies already exist" + return 0 + fi log_info "Running pnpm install..." pnpm install log_info "Dependencies installed" @@ -211,17 +198,19 @@ start_mock_server() { mkdir -p "$LOG_DIR" - # Kill existing process on port if lsof -ti:"${MOCK_SERVER_PORT}" > /dev/null 2>&1; then - log_warn "Port ${MOCK_SERVER_PORT} is already in use. Killing existing process..." - lsof -ti:"${MOCK_SERVER_PORT}" | xargs kill -9 2>/dev/null || true - sleep 1 + if curl --fail --silent "http://localhost:${MOCK_SERVER_PORT}/health" > /dev/null; then + log_info "Reusing the existing mock server" + return 0 + fi + log_error "Mock server port ${MOCK_SERVER_PORT} is occupied by another service" + exit 1 fi - + cd "$ROOT_DIR" log_info "Starting mock server on port ${MOCK_SERVER_PORT}..." - PORT="${MOCK_SERVER_PORT}" pnpm --dir "$ROOT_DIR" serve:mocks 2>&1 | tee "$MOCK_SERVER_LOG" & + PORT="${MOCK_SERVER_PORT}" pnpm --dir "$ROOT_DIR" serve:mocks > "$MOCK_SERVER_LOG" 2>&1 & MOCK_SERVER_PID=$! log_info "Mock server started with PID: $MOCK_SERVER_PID" @@ -233,36 +222,38 @@ start_mock_server() { fi } +run_native_cli() { + MOCK_SERVER_PORT="$MOCK_SERVER_PORT" \ + pnpm --dir "$RN_PACKAGE_DIR" exec bash -c 'cd "$1"; shift; exec react-native "$@"' -- "$DEV_DIR" "$@" +} + start_metro() { log_step "Starting Metro Bundler" mkdir -p "$LOG_DIR" - # Kill existing process on port if lsof -ti:"${METRO_PORT}" > /dev/null 2>&1; then - log_warn "Port ${METRO_PORT} is already in use. Killing existing process..." - lsof -ti:"${METRO_PORT}" | xargs kill -9 2>/dev/null || true - sleep 1 + if [[ "$(curl --fail --silent "http://localhost:${METRO_PORT}/status")" == "packager-status:running" ]]; then + log_info "Reusing the existing Metro bundler" + return 0 + fi + log_error "Metro port ${METRO_PORT} is occupied by another service" + exit 1 fi - + cd "$DEV_DIR" log_info "Starting Metro bundler on port ${METRO_PORT}..." - npx react-native start --port "$METRO_PORT" --config "$DEV_DIR/metro.config.js" 2>&1 | tee "$METRO_LOG" & + run_native_cli start --port "$METRO_PORT" --config "$DEV_DIR/metro.config.cjs" > "$METRO_LOG" 2>&1 & METRO_PID=$! log_info "Metro bundler started with PID: $METRO_PID" - # Wait for Metro to initialize - log_info "Waiting for Metro bundler to initialize..." - sleep 10 - - if ! kill -0 "$METRO_PID" 2>/dev/null; then - log_error "Metro bundler failed to start. Check logs at: $METRO_LOG" + if ! wait_for_port "${METRO_PORT}" "Metro" 30; then cat "$METRO_LOG" exit 1 fi - + log_info "Metro bundler is running" } @@ -272,18 +263,11 @@ setup_ios() { cd "$DEV_DIR/ios" if [[ "$CLEAN_BUILD" == true ]]; then - log_info "Cleaning iOS build..." - rm -rf ~/Library/Developer/Xcode/DerivedData 2>/dev/null || true - rm -rf Pods Podfile.lock build 2>/dev/null || true - # Clean CocoaPods cache to fix "null byte" errors - pod cache clean --all 2>/dev/null || true + log_info "Cleaning this harness's iOS build..." + xcodebuild -workspace ReactNativeApp.xcworkspace -scheme ReactNativeApp clean fi - + log_info "Installing CocoaPods..." - # Clean existing pods to avoid null byte issues in monorepos - if [[ "$CLEAN_BUILD" == true ]]; then - rm -rf Pods Podfile.lock 2>/dev/null || true - fi pod install --repo-update log_info "iOS setup complete" @@ -321,10 +305,10 @@ run_app() { if [[ "$PLATFORM" == "ios" ]]; then log_info "Building and launching iOS app..." - npx react-native run-ios --no-packager --port "$METRO_PORT" + run_native_cli run-ios --no-packager --port "$METRO_PORT" else log_info "Building and launching Android app..." - npx react-native run-android --no-packager --port "$METRO_PORT" + run_native_cli run-android --no-packager --port "$METRO_PORT" fi } @@ -356,7 +340,11 @@ wait_forever() { log_info "Press Ctrl+C to stop all servers and exit" # Wait for Metro process (it will run until killed) - wait "$METRO_PID" 2>/dev/null || true + if [[ -n "$METRO_PID" ]]; then + wait "$METRO_PID" 2>/dev/null || true + else + while true; do sleep 1; done + fi } # ============================================================================= @@ -423,10 +411,12 @@ main() { install_dependencies - if [[ "$PLATFORM" == "ios" ]]; then - setup_ios - else - setup_android + if [[ "$START_APP" == true ]]; then + if [[ "$PLATFORM" == "ios" ]]; then + setup_ios + else + setup_android + fi fi start_mock_server diff --git a/packages/react-native-sdk/dev/utils/sdkHelpers.ts b/packages/react-native-sdk/dev/utils/sdkHelpers.ts index 5ccf26896..7716d6f74 100644 --- a/packages/react-native-sdk/dev/utils/sdkHelpers.ts +++ b/packages/react-native-sdk/dev/utils/sdkHelpers.ts @@ -1,48 +1,5 @@ -import type { ContentfulOptimization } from '@contentful/optimization-react-native' import { createClient, type Entry } from 'contentful' -import AsyncStorageStore from '../../src/storage/AsyncStorageStore' import { ENV_CONFIG } from '../env.config' -import type { SDKInfo } from '../types' - -export async function initializeSDK( - setSdkInfo: (info: SDKInfo) => void, - setSdk: (sdk: ContentfulOptimization) => void, - setSdkLoaded: (loaded: boolean) => void, - setSdkError: (error: string | null) => void, -): Promise { - const { ContentfulOptimization } = await import('@contentful/optimization-react-native') - - try { - const { - optimization: { spaceId, environment }, - api: { experienceBaseUrl, insightsBaseUrl }, - } = ENV_CONFIG - - await AsyncStorageStore.initializeConsentState() - AsyncStorageStore.consent = true - - const sdkInstance = await ContentfulOptimization.initialize({ - spaceId, - environment, - api: { - experienceBaseUrl, - insightsBaseUrl, - }, - logLevel: 'debug', - }) - - setSdkInfo({ - spaceId, - environment, - initialized: true, - timestamp: new Date().toISOString(), - }) - setSdk(sdkInstance) - setSdkLoaded(true) - } catch (error) { - setSdkError(error instanceof Error ? error.message : 'Unknown error') - } -} export async function fetchEntriesFromMockServer( setOptimizedEntry: (entry: Entry) => void, diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index fe2babab7..0634ad030 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -154,10 +154,10 @@ "size:report": "build-tools bundle-size --report-only", "test:unit": "rstest run --coverage", "typecheck": "tsc --noEmit", - "dev:android": "cd dev && react-native run-android", - "dev:ios": "cd dev && react-native run-ios", - "dev:start": "cd dev && react-native start", - "dev:start:clean": "kill-port 8081 && cd dev && react-native start --reset-cache", + "dev:android": "cd dev && react-native run-android --no-packager", + "dev:ios": "cd dev && react-native run-ios --no-packager", + "dev:start": "cd dev && react-native start --config metro.config.cjs", + "dev:start:clean": "cd dev && react-native start --config metro.config.cjs --reset-cache", "dev:test": "jest --config dev/jest.config.cjs --passWithNoTests", "dev:setup:ios": "./dev/scripts/run-dev-dashboard.sh --ios", "dev:setup:android": "./dev/scripts/run-dev-dashboard.sh --android", diff --git a/packages/react-native-sdk/react-native.config.cjs b/packages/react-native-sdk/react-native.config.cjs new file mode 100644 index 000000000..d6e19fc15 --- /dev/null +++ b/packages/react-native-sdk/react-native.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + root: __dirname, + project: { + android: { sourceDir: 'dev/android' }, + ios: { sourceDir: 'dev/ios' }, + }, +} diff --git a/packages/web/frameworks/react-web-sdk/dev/app/App.tsx b/packages/web/frameworks/react-web-sdk/dev/app/App.tsx index 20e305e58..e9520e600 100644 --- a/packages/web/frameworks/react-web-sdk/dev/app/App.tsx +++ b/packages/web/frameworks/react-web-sdk/dev/app/App.tsx @@ -4,13 +4,7 @@ import type { Entry, EntrySkeletonType } from 'contentful' import { type ReactElement, useMemo, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { useEntryResolver, useLiveUpdates, useOptimization } from '../../src' -import { - BASELINE_IDS, - CONTENTFUL_SPACE_ID, - ENVIRONMENT, - EXPERIENCE_BASE_URL, - INSIGHTS_BASE_URL, -} from './constants' +import { BASELINE_IDS } from './constants' import { useDevEntries } from './hooks/useDevEntries' import { useOptimizationState } from './hooks/useOptimizationState' import { ControlsSection } from './sections/ControlsSection' @@ -120,6 +114,10 @@ export function App(): ReactElement {

@contentful/optimization-react-web

Dev app wired to the React Router auto-page adapter.

+

+ Preview and commit a paired handoff (start the Node harness + on port 3001). Standalone browser flow. +