From 1c79f51825b8a2270b6825fea1049043aa00ae78 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Mon, 20 Oct 2025 18:18:02 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A1=20refactor(state):=20Rearranging?= =?UTF-8?q?=20states=20and=20fixing=20pm2=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the relevant states to the modules that rely on them most. This allows us to more easily separate Personalization and Analytics in the future, if that ever makes sense. However, `profile` may be updated twice if the customer sets the defaults for both personalization and analytics, since it applies to both. Also, while the Core code can remain oblivious to the two modules' individual states collections, SDKs that rely on core may still need to be aware of the structure of the defaults for both modules for the purposes of caching. NoClose [[NT-1687](https://contentful.atlassian.net/browse/NT-1687)] --- implementations/node/package.json | 6 +- implementations/web-vanilla/package.json | 4 +- package.json | 4 +- platforms/javascript/core/src/CoreBase.ts | 63 ++++++++++++------- platforms/javascript/core/src/CoreStateful.ts | 48 +------------- .../javascript/core/src/CoreStateless.ts | 4 +- .../core/src/analytics/AnalyticsBase.ts | 23 ++++++- .../src/personalization/Personalization.ts | 48 ++++++++++++-- platforms/javascript/core/src/signals.ts | 20 ++++++ .../javascript/react-native/src/index.ts | 23 +++++-- platforms/javascript/web/src/index.ts | 23 +++++-- pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 3 + 13 files changed, 181 insertions(+), 91 deletions(-) diff --git a/implementations/node/package.json b/implementations/node/package.json index edcfdda1..b92a48e2 100644 --- a/implementations/node/package.json +++ b/implementations/node/package.json @@ -8,9 +8,9 @@ "scripts": { "clean": "rimraf ./coverage ./playwright-report ./test-results", "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:app": "pm2 start \"tsx --env-file=.env ./src/app.ts\"", - "serve:mocks": "pm2 start \"pnpm --filter mocks mocks:serve\"", - "serve:stop": "pm2 stop all && pm2 delete all", + "serve:app": "pm2 start --name node-app \"tsx --env-file=.env ./src/app.ts\"", + "serve:mocks": "pm2 start --name node-mocks \"pnpm --filter mocks mocks:serve\"", + "serve:stop": "pm2 stop node-app node-mocks && pm2 delete node-app node-mocks", "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:codegen": "playwright codegen", "test:e2e:report": "playwright show-report", diff --git a/implementations/web-vanilla/package.json b/implementations/web-vanilla/package.json index 898b8fb7..7b445e6b 100644 --- a/implementations/web-vanilla/package.json +++ b/implementations/web-vanilla/package.json @@ -9,8 +9,8 @@ "clean": "rimraf ./public/dist ./coverage ./playwright-report ./test-results tsconfig.tsbuildinfo", "serve": "pnpm serve:mocks && pnpm serve:app", "serve:app": "pnpm build && docker compose up -d", - "serve:mocks": "pm2 start \"pnpm --filter mocks mocks:serve\"", - "serve:stop": "docker compose down && pm2 stop all && pm2 delete all", + "serve:mocks": "pm2 start --name web-mocks \"pnpm --filter mocks mocks:serve\"", + "serve:stop": "docker compose down && pm2 stop web-mocks && pm2 delete web-mocks", "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:codegen": "playwright codegen", "test:e2e:report": "playwright show-report", diff --git a/package.json b/package.json index f3852ebc..bf22c8d1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Contentful Optimization SDK Suite", "license": "MIT", "scripts": { + "pm2:list": "pm2 list", "prepare": "husky", "build": "pnpm --filter @contentful/* build", "build:implementations": "pnpm --filter @implementation/* build", @@ -13,7 +14,7 @@ "format:fix": "prettier . --check --write", "lint:check": "eslint .", "lint:fix": "eslint . --fix", - "test:e2e": "pnpm --filter @implementation/* test:e2e", + "test:e2e": "pnpm --parallel --filter @implementation/* test:e2e", "test:unit": "pnpm --filter @contentful/* test:unit", "test:unit:implementations": "pnpm --filter @implementation/* test:unit", "typecheck": "pnpm -r typecheck", @@ -34,6 +35,7 @@ "husky": "^9.1.7", "jiti": "^2.5.1", "lint-staged": "^16.1.2", + "pm2": "catalog:", "prettier": "^3.6.2", "prettier-plugin-organize-imports": "^4.2.0", "typedoc": "^0.28.8", diff --git a/platforms/javascript/core/src/CoreBase.ts b/platforms/javascript/core/src/CoreBase.ts index 03809000..785354db 100644 --- a/platforms/javascript/core/src/CoreBase.ts +++ b/platforms/javascript/core/src/CoreBase.ts @@ -1,23 +1,31 @@ import ApiClient, { EventBuilder, + type InsightsEvent as AnalyticsEvent, type ApiClientConfig, - type ChangeArray, type EventBuilderConfig, type GlobalApiConfigProperties, - type Profile, - type SelectedPersonalizationArray, + type ExperienceEvent as PersonalizationEvent, } from '@contentful/optimization-api-client' import type { LogLevels } from 'logger' import { ConsoleLogSink, logger } from 'logger' import type AnalyticsBase from './analytics/AnalyticsBase' -import { Personalization } from './personalization' -import { batch, changes, consent, personalizations, profile } from './signals' +import type { AnalyticsConfigDefaults, AnalyticsStates } from './analytics/AnalyticsBase' +import { + Personalization, + type PersonalizationConfigDefaults, + type PersonalizationStates, +} from './personalization' +import { consent, event, toObservable, type Observable } from './signals' export interface CoreConfigDefaults { - changes?: ChangeArray consent?: boolean - profile?: Profile - personalizations?: SelectedPersonalizationArray + personalization?: PersonalizationConfigDefaults + analytics?: AnalyticsConfigDefaults +} + +export interface CoreStates extends AnalyticsStates, PersonalizationStates { + consent: Observable + eventStream: Observable } /** Options that may be passed to the Core constructor */ @@ -50,20 +58,9 @@ abstract class CoreBase implements ConsentController { logger.addSink(new ConsoleLogSink(logLevel)) - if (defaults) { - const { - changes: defaultChanges, - consent: defaultConsent, - personalizations: defaultPersonalizations, - profile: defaultProfile, - } = defaults - - batch(() => { - changes.value = defaultChanges - consent.value = defaultConsent - personalizations.value = defaultPersonalizations - profile.value = defaultProfile - }) + if (defaults?.consent !== undefined) { + const { consent: defaultConsent } = defaults + consent.value = defaultConsent } const apiConfig = { @@ -82,12 +79,30 @@ abstract class CoreBase implements ConsentController { }, ) - this.personalization = new Personalization(this.api, this.eventBuilder) + this.personalization = new Personalization( + this.api, + this.eventBuilder, + defaults?.personalization, + ) + } + + get states(): CoreStates { + return { + ...this.analytics.states, + ...this.personalization.states, + consent: toObservable(consent), + eventStream: toObservable(event), + } } - public consent(accept: boolean): void { + consent(accept: boolean): void { consent.value = accept } + + /** Do not reset consent, resetting personalization _currently_ also resets analytics' dependencies */ + reset(): void { + this.personalization.reset() + } } export default CoreBase diff --git a/platforms/javascript/core/src/CoreStateful.ts b/platforms/javascript/core/src/CoreStateful.ts index 375e503b..0fe88d54 100644 --- a/platforms/javascript/core/src/CoreStateful.ts +++ b/platforms/javascript/core/src/CoreStateful.ts @@ -1,57 +1,15 @@ -import type { - InsightsEvent as AnalyticsEvent, - Flags, - ExperienceEvent as PersonalizationEvent, - Profile, - SelectedPersonalizationArray, -} from '@contentful/optimization-api-client' import { AnalyticsStateful } from './analytics' import CoreBase, { type CoreConfig } from './CoreBase' -import { consent, effect, event, flags, personalizations, profile } from './signals' - -export interface Subscription { - unsubscribe: () => void -} - -export interface Observable { - subscribe: (next: (v: T) => void) => Subscription -} - -export interface States { - consent: Observable - eventStream: Observable - flags: Observable - profile: Observable - personalizations: Observable -} - -function toObservable(s: { value: T }): Observable { - return { - subscribe(next) { - const dispose = effect(() => { - next(s.value) - }) - - return { unsubscribe: dispose } - }, - } -} class CoreStateful extends CoreBase { readonly analytics: AnalyticsStateful - readonly states: States = { - consent: toObservable(consent), - eventStream: toObservable(event), - flags: toObservable(flags), - profile: toObservable(profile), - personalizations: toObservable(personalizations), - } - constructor(config: CoreConfig) { super(config) - this.analytics = new AnalyticsStateful(this.api, this.eventBuilder) + const { defaults } = config + + this.analytics = new AnalyticsStateful(this.api, this.eventBuilder, defaults?.analytics) } } diff --git a/platforms/javascript/core/src/CoreStateless.ts b/platforms/javascript/core/src/CoreStateless.ts index 9dbab449..3cd7377f 100644 --- a/platforms/javascript/core/src/CoreStateless.ts +++ b/platforms/javascript/core/src/CoreStateless.ts @@ -7,7 +7,9 @@ class CoreStateless extends CoreBase { constructor(config: CoreConfig) { super(config) - this.analytics = new AnalyticsStateless(this.api, this.eventBuilder) + const { defaults } = config + + this.analytics = new AnalyticsStateless(this.api, this.eventBuilder, defaults?.analytics) } } diff --git a/platforms/javascript/core/src/analytics/AnalyticsBase.ts b/platforms/javascript/core/src/analytics/AnalyticsBase.ts index 266da553..214fb0c9 100644 --- a/platforms/javascript/core/src/analytics/AnalyticsBase.ts +++ b/platforms/javascript/core/src/analytics/AnalyticsBase.ts @@ -1,13 +1,30 @@ import type ApiClient from '@contentful/optimization-api-client' -import type { EventBuilder, InsightsEvent } from '@contentful/optimization-api-client' +import type { EventBuilder, InsightsEvent, Profile } from '@contentful/optimization-api-client' import { logger } from 'logger' import ProductBase, { type ConsentGuard } from '../ProductBase' -import { consent, effect, profile } from '../signals' +import { consent, effect, type Observable, profile, toObservable } from '../signals' + +export interface AnalyticsConfigDefaults { + profile?: Profile +} + +export interface AnalyticsStates { + profile: Observable +} abstract class AnalyticsBase extends ProductBase implements ConsentGuard { - constructor(api: ApiClient, builder: EventBuilder) { + readonly states: AnalyticsStates = { + profile: toObservable(profile), + } + + constructor(api: ApiClient, builder: EventBuilder, defaults?: AnalyticsConfigDefaults) { super(api, builder) + if (defaults?.profile !== undefined) { + const { profile: defaultProfile } = defaults + profile.value = defaultProfile + } + effect(() => { const id = profile.value?.id diff --git a/platforms/javascript/core/src/personalization/Personalization.ts b/platforms/javascript/core/src/personalization/Personalization.ts index 9c0b389d..e45a594b 100644 --- a/platforms/javascript/core/src/personalization/Personalization.ts +++ b/platforms/javascript/core/src/personalization/Personalization.ts @@ -1,15 +1,18 @@ import type ApiClient from '@contentful/optimization-api-client' import { + type ChangeArray, type ComponentViewBuilderArgs, ComponentViewEvent, type EventBuilder, - type ExperienceEvent, type Flags, type IdentifyBuilderArgs, IdentifyEvent, type OptimizationData, type PageViewBuilderArgs, PageViewEvent, + type ExperienceEvent as PersonalizationEvent, + type Profile, + type SelectedPersonalizationArray, type TrackBuilderArgs, TrackEvent, } from '@contentful/optimization-api-client' @@ -24,18 +27,55 @@ import { consent, effect, event as eventSignal, + flags, flags as flagsSignal, + type Observable, + personalizations, personalizations as personalizationsSignal, + profile, profile as profileSignal, + toObservable, } from '../signals' import { PersonalizedEntryResolver } from './resolvers' -class Personalization extends ProductBase implements ConsentGuard { +export interface PersonalizationConfigDefaults { + changes?: ChangeArray + profile?: Profile + personalizations?: SelectedPersonalizationArray +} + +export interface PersonalizationStates { + flags: Observable + profile: Observable + personalizations: Observable +} + +class Personalization extends ProductBase implements ConsentGuard { readonly personalizedEntryResolver = PersonalizedEntryResolver - constructor(api: ApiClient, builder: EventBuilder) { + readonly states: PersonalizationStates = { + flags: toObservable(flags), + profile: toObservable(profile), + personalizations: toObservable(personalizations), + } + + constructor(api: ApiClient, builder: EventBuilder, defaults?: PersonalizationConfigDefaults) { super(api, builder) + if (defaults) { + const { + changes: defaultChanges, + personalizations: defaultPersonalizations, + profile: defaultProfile, + } = defaults + + batch(() => { + changesSignal.value = defaultChanges + personalizationsSignal.value = defaultPersonalizations + profileSignal.value = defaultProfile + }) + } + effect(() => { logger.info( `[Personalization] Profile ${profileSignal.value && `with ID ${profileSignal.value.id}`} has been ${profileSignal.value ? 'set' : 'cleared'}`, @@ -114,7 +154,7 @@ class Personalization extends ProductBase implements ConsentGua return await this.#upsertProfile(event) } - async #upsertProfile(event: ExperienceEvent): Promise { + async #upsertProfile(event: PersonalizationEvent): Promise { const intercepted = await this.interceptor.event.run(event) eventSignal.value = intercepted diff --git a/platforms/javascript/core/src/signals.ts b/platforms/javascript/core/src/signals.ts index d3564695..a3b19448 100644 --- a/platforms/javascript/core/src/signals.ts +++ b/platforms/javascript/core/src/signals.ts @@ -32,6 +32,26 @@ export interface Signals { personalizations: typeof personalizations } +export interface Subscription { + unsubscribe: () => void +} + +export interface Observable { + subscribe: (next: (v: T) => void) => Subscription +} + +export function toObservable(s: { value: T }): Observable { + return { + subscribe(next) { + const dispose = effect(() => { + next(s.value) + }) + + return { unsubscribe: dispose } + }, + } +} + export const signals: Signals = { changes, consent, diff --git a/platforms/javascript/react-native/src/index.ts b/platforms/javascript/react-native/src/index.ts index 9c7a2ad6..8270aa4b 100644 --- a/platforms/javascript/react-native/src/index.ts +++ b/platforms/javascript/react-native/src/index.ts @@ -7,13 +7,28 @@ async function mergeConfig({ defaults, logLevel, ...config }: CoreConfig): Promi // Initialize AsyncStorage before reading from it await AsyncStorageStore.initialize() + const { + consent = AsyncStorageStore.consent, + analytics: { profile: analyticsProfile = AsyncStorageStore.profile } = {}, + personalization: { + changes = AsyncStorageStore.changes, + profile: personalizationProfile = AsyncStorageStore.profile, + personalizations = AsyncStorageStore.personalizations, + } = {}, + } = defaults ?? {} + return merge( { defaults: { - changes: AsyncStorageStore.changes ?? defaults?.changes, - consent: AsyncStorageStore.consent ?? defaults?.consent, - profile: AsyncStorageStore.profile ?? defaults?.profile, - personalizations: AsyncStorageStore.personalizations ?? defaults?.personalizations, + consent, + analytics: { + profile: analyticsProfile, + }, + personalization: { + changes, + profile: personalizationProfile, + personalizations, + }, }, eventBuilder: { channel: 'react-native', diff --git a/platforms/javascript/web/src/index.ts b/platforms/javascript/web/src/index.ts index c535e47a..0bdf00d2 100644 --- a/platforms/javascript/web/src/index.ts +++ b/platforms/javascript/web/src/index.ts @@ -19,16 +19,31 @@ declare global { } function mergeConfig({ defaults, logLevel, ...config }: CoreConfig): CoreConfig { + const { + consent = LocalStore.consent, + analytics: { profile: analyticsProfile = LocalStore.profile } = {}, + personalization: { + changes = LocalStore.changes, + profile: personalizationProfile = LocalStore.profile, + personalizations = LocalStore.personalizations, + } = {}, + } = defaults ?? {} + return merge( { api: { analytics: { beaconHandler }, }, defaults: { - changes: LocalStore.changes ?? defaults?.changes, - consent: LocalStore.consent ?? defaults?.consent, - profile: LocalStore.profile ?? defaults?.profile, - personalizations: LocalStore.personalizations ?? defaults?.personalizations, + consent, + analytics: { + profile: analyticsProfile, + }, + personalization: { + changes, + profile: personalizationProfile, + personalizations, + }, }, eventBuilder: { channel: 'web', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef3cefb0..5ce94b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: lint-staged: specifier: ^16.1.2 version: 16.1.4 + pm2: + specifier: 'catalog:' + version: 6.0.13 prettier: specifier: ^3.6.2 version: 3.6.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b42f4f9..dac37601 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,9 +29,12 @@ nodeVersion: 20.19.5 onlyBuiltDependencies: - contentful-cli + - detox + - dtrace-provider - esbuild - msw - spawn-sync + - unrs-resolver overrides: '@playwright/test': 'catalog:'