From 9ff1815be59152eb3c68acfd7099000e2cffeb2d Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Tue, 9 Jun 2026 15:35:13 +0200 Subject: [PATCH 1/5] refactor [NT-3230]: Use shared Preview Module in Web Preview Panel --- packages/web/preview-panel/package.json | 1 + .../src/attachOptimizationPreviewPanel.ts | 217 ++++++++++-------- .../web/preview-panel/src/lib/overrides.ts | 41 ---- 3 files changed, 127 insertions(+), 132 deletions(-) diff --git a/packages/web/preview-panel/package.json b/packages/web/preview-panel/package.json index 356775ad..fc7318a0 100644 --- a/packages/web/preview-panel/package.json +++ b/packages/web/preview-panel/package.json @@ -70,6 +70,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@contentful/optimization-core": "workspace:*", "@contentful/optimization-web": "workspace:*", "@leeoniya/ufuzzy": "^1.0.19", "@lit/context": "^1.1.6", diff --git a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts index 30da0652..9461795d 100644 --- a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts +++ b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts @@ -1,3 +1,4 @@ +import { PreviewOverrideManager } from '@contentful/optimization-core/preview-support' import type ContentfulOptimization from '@contentful/optimization-web' import type { AudienceEntrySkeleton, @@ -5,17 +6,11 @@ import type { OptimizationData, OptimizationEntry, OptimizationEntrySkeleton, + Profile, SelectedOptimizationArray, } from '@contentful/optimization-web/api-schemas' -import type { - PreviewPanelSignalObject, - SignalFns, - Signals, -} from '@contentful/optimization-web/core-sdk' -import { - PREVIEW_PANEL_SIGNALS_SYMBOL, - PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, -} from '@contentful/optimization-web/symbols' +import type { PreviewPanelSignalObject } from '@contentful/optimization-web/core-sdk' +import { PREVIEW_PANEL_SIGNALS_SYMBOL } from '@contentful/optimization-web/symbols' import type { ChainModifiers, ContentfulClientApi, Entry } from 'contentful' import { AUDIENCE_SWITCH_CHANGE, @@ -39,7 +34,7 @@ import { } from './components/panel' import { defineSearch } from './components/search' import { getAllEntries, isAudienceEntry, isOptimizationEntry } from './lib/entries' -import { applyChangeOverrides, applyOptimizationOverrides } from './lib/overrides' +import { applyChangeOverrides } from './lib/overrides' import { isChange, isSelectedOptimization } from './lib/schemaGuards' import { createScopedLogger } from './logger' @@ -55,7 +50,7 @@ declare global { } /** @internal */ -let defaults: { +let storedDefaults: { selectedOptimizations?: SelectedOptimizationArray changes?: ChangeArray } = {} @@ -63,8 +58,6 @@ let defaults: { /** @internal */ let previewPanelAttachment: Promise | undefined = undefined -/** @internal */ -const overrides = new Map() const OVERRIDES_STORAGE_KEY = '__ctfl_opt_preview_overrides__' const DEFAULTS_STORAGE_KEY = '__ctfl_opt_preview_defaults__' const storageLogger = createScopedLogger('PreviewPanelStorage') @@ -75,15 +68,15 @@ function isRecord(value: unknown): value is Record { } /** @internal */ -function loadOverrides(): void { - overrides.clear() +function loadOverridesFromStorage(): Map { + const overrides = new Map() try { const stored = localStorage.getItem(OVERRIDES_STORAGE_KEY) - if (!stored) return + if (!stored) return overrides const parsed = JSON.parse(stored) as unknown - if (!isRecord(parsed)) return + if (!isRecord(parsed)) return overrides for (const [experienceId, variantIndex] of Object.entries(parsed)) { if (typeof variantIndex === 'number') overrides.set(experienceId, variantIndex) @@ -91,11 +84,13 @@ function loadOverrides(): void { } catch (error) { storageLogger.warn(`Failed to read localStorage key "${OVERRIDES_STORAGE_KEY}"`, error) } + + return overrides } /** @internal */ -function loadDefaults(): void { - defaults = {} +function loadDefaultsFromStorage(): void { + storedDefaults = {} try { const stored = localStorage.getItem(DEFAULTS_STORAGE_KEY) @@ -105,19 +100,19 @@ function loadDefaults(): void { if (!isRecord(parsed)) return const { selectedOptimizations: storedSelectedOptimizations, changes: storedChanges } = parsed - defaults.selectedOptimizations = + storedDefaults.selectedOptimizations = Array.isArray(storedSelectedOptimizations) && storedSelectedOptimizations.every(isSelectedOptimization) ? storedSelectedOptimizations : undefined - defaults.changes = + storedDefaults.changes = Array.isArray(storedChanges) && storedChanges.every(isChange) ? storedChanges : undefined } catch (error) { storageLogger.warn(`Failed to read localStorage key "${DEFAULTS_STORAGE_KEY}"`, error) } } -function persistOverrideState(): void { +function persistOverrideState(overrides: Map): void { try { if (overrides.size === 0) { localStorage.removeItem(OVERRIDES_STORAGE_KEY) @@ -126,48 +121,14 @@ function persistOverrideState(): void { } localStorage.setItem(OVERRIDES_STORAGE_KEY, JSON.stringify(Object.fromEntries(overrides))) - if ((defaults.selectedOptimizations ?? defaults.changes) !== undefined) { - localStorage.setItem(DEFAULTS_STORAGE_KEY, JSON.stringify(defaults)) + if ((storedDefaults.selectedOptimizations ?? storedDefaults.changes) !== undefined) { + localStorage.setItem(DEFAULTS_STORAGE_KEY, JSON.stringify(storedDefaults)) } } catch (error) { storageLogger.warn('Failed to persist preview panel override state', error) } } -/** @internal */ -function syncOverrides( - panel: { - optimizationEntries: OptimizationEntry[] - defaultSelectedOptimizations: SelectedOptimizationArray - overrides: Map | undefined - }, - signals: Pick, - signalFns: Pick, -): void { - if (defaults.selectedOptimizations === undefined && signals.selectedOptimizations.value) { - defaults.selectedOptimizations = [...signals.selectedOptimizations.value] - panel.defaultSelectedOptimizations = [...defaults.selectedOptimizations] - } - - if (defaults.changes === undefined && signals.changes.value) { - defaults.changes = [...signals.changes.value] - } - - panel.overrides = new Map(overrides) - signalFns.batch(() => { - signals.selectedOptimizations.value = applyOptimizationOverrides( - defaults.selectedOptimizations ?? [], - overrides, - ) - signals.changes.value = applyChangeOverrides( - defaults.changes ?? [], - panel.optimizationEntries, - overrides, - ) - }) - persistOverrideState() -} - /** * Throws if a preview panel element already exists in the DOM. * @@ -230,12 +191,28 @@ export interface AttachOptimizationPreviewPanelArgs` shape consumed by the panel UI and `applyChangeOverrides`. + * + * @internal + */ +function overridesToMap( + selectedOptimizations: Record, +): Map { + const result = new Map() + for (const { experienceId, variantIndex } of Object.values(selectedOptimizations)) { + result.set(experienceId, variantIndex) + } + return result +} + /** * Attaches the ContentfulOptimization preview panel to the supplied SDK instance. * * Registers all custom elements, fetches audiences and optimization entries from - * Contentful, wires up state interceptors, and appends the panel to - * `document.body`. + * Contentful, wires up state interceptors via the shared + * {@link PreviewOverrideManager}, and appends the panel to `document.body`. * * @param args - Configuration containing the Contentful client, ContentfulOptimization instance, and optional CSP nonce. * @returns Resolves once the panel has been appended to the document body. @@ -258,15 +235,14 @@ async function attachOptimizationPreviewPanelToSdk isOptimizationEntry(optimization), ) + panel.defaultSelectedOptimizations = [...(storedDefaults.selectedOptimizations ?? [])] + panel.overrides = new Map(initialOverrides) + + const manager = new PreviewOverrideManager({ + selectedOptimizations: signals.selectedOptimizations, + profile: signals.profile, + stateInterceptors: contentfulOptimization.interceptors.state, + onOverridesChanged: (state) => { + const overridesMap = overridesToMap(state.selectedOptimizations) + panel.overrides = new Map(overridesMap) + persistOverrideState(overridesMap) + }, + }) + // Web-only interceptor: keep the inline-variable Variable changes in sync + // with overrides. The manager's interceptor runs first and rewrites + // selectedOptimizations to honor overrides; we read the clean baseline back + // from the manager so storedDefaults / panel.defaultSelectedOptimizations + // reflect the un-overridden state. contentfulOptimization.interceptors.state.add((states): OptimizationData => { - const { changes, selectedOptimizations, ...otherStates } = states + const { changes, ...otherStates } = states + const baseline = manager.getBaselineSelectedOptimizations() ?? states.selectedOptimizations - defaults = { - selectedOptimizations: [...selectedOptimizations], + storedDefaults = { + selectedOptimizations: [...baseline], changes: [...changes], } - panel.defaultSelectedOptimizations = [...selectedOptimizations] - if (overrides.size > 0) persistOverrideState() + panel.defaultSelectedOptimizations = [...baseline] + + const overridesMap = overridesToMap(manager.getOverrides().selectedOptimizations) + if (overridesMap.size > 0) persistOverrideState(overridesMap) return { ...otherStates, - changes: applyChangeOverrides(changes, panel.optimizationEntries, overrides), - selectedOptimizations: applyOptimizationOverrides(selectedOptimizations, overrides), + selectedOptimizations: states.selectedOptimizations, + changes: applyChangeOverrides(changes, panel.optimizationEntries, overridesMap), } }) @@ -328,43 +323,83 @@ async function attachOptimizationPreviewPanelToSdk { + if (audienceId) { + manager.resetAudienceOverride(audienceId) + return + } + for (const experienceId of experienceIds) { + manager.resetOptimizationOverride(experienceId) + } + } + + const onAudienceSwitchActivate = ( + audienceId: string, + experienceIds: string[], + variantIndex: number, + ): void => { + if (variantIndex === 1) { + manager.activateAudience(audienceId, experienceIds) + } else { + manager.deactivateAudience(audienceId, experienceIds) + } + } + panel.addEventListener(AUDIENCE_SWITCH_CHANGE, (event: Event) => { if (!isAudienceSwitchChangeEvent(event)) return const [target] = event.composedPath() if (!(target instanceof Element) || !isAudience(target)) return - for (const { - fields: { nt_experience_id: experienceId }, - } of target.optimizations) { - overrides.delete(experienceId) + const audienceId = target.audience?.sys.id + const experienceIds = target.optimizations.map(({ fields: { nt_experience_id: id } }) => id) + + if (event.detail.length === 0) { + onAudienceSwitchReset(audienceId, experienceIds) + return } - for (const { key: experienceId, value: variantIndex } of event.detail) { - overrides.set(experienceId, variantIndex) + if (!audienceId) { + for (const { key, value } of event.detail) { + manager.setVariantOverride(key, value) + } + return } - syncOverrides(panel, signals, signalFns) + const variantIndex = event.detail[0]?.value ?? 0 + onAudienceSwitchActivate(audienceId, experienceIds, variantIndex) }) panel.addEventListener(PANEL_RESET, () => { - overrides.clear() - syncOverrides(panel, signals, signalFns) - panel.defaultSelectedOptimizations = [...(defaults.selectedOptimizations ?? [])] + manager.resetAll() + panel.defaultSelectedOptimizations = [...(storedDefaults.selectedOptimizations ?? [])] }) - signalFns.effect(() => { - const { - profile: { value: profile }, - } = signals + signals.profile.subscribe((profile: Profile | undefined) => { if (profile) panel.profile = profile }) - if (overrides.size > 0) { - syncOverrides(panel, signals, signalFns) + // Hydrate overrides loaded from storage into the manager so its state + // interceptor and downstream signals reflect them on first render. The + // manager re-derives `signals.selectedOptimizations` from its baseline on + // every setVariantOverride call. + if (initialOverrides.size > 0) { + for (const [experienceId, variantIndex] of initialOverrides) { + manager.setVariantOverride(experienceId, variantIndex) + } + + // Re-apply change overrides against the cached changes baseline so + // inline-variable flag values reflect the restored selection until the + // next API refresh runs the interceptor chain. + if (storedDefaults.changes) { + signals.changes.value = applyChangeOverrides( + storedDefaults.changes, + panel.optimizationEntries, + initialOverrides, + ) + } } document.body.appendChild(panel) diff --git a/packages/web/preview-panel/src/lib/overrides.ts b/packages/web/preview-panel/src/lib/overrides.ts index 353cf381..b737d9d2 100644 --- a/packages/web/preview-panel/src/lib/overrides.ts +++ b/packages/web/preview-panel/src/lib/overrides.ts @@ -2,7 +2,6 @@ import type { ChangeArray, InlineVariableComponent, OptimizationEntry, - SelectedOptimizationArray, } from '@contentful/optimization-web/api-schemas' import { isInlineVariableComponent } from './schemaGuards' @@ -11,46 +10,6 @@ function getInlineVariableComponents(optimization: OptimizationEntry): InlineVar return Array.isArray(components) ? components.filter(isInlineVariableComponent) : [] } -/** - * Merges user-selected variant overrides into the given selected optimizations. - * - * Existing entries whose experience ID appears in the overrides map have their - * `variantIndex` replaced. Override entries not already present in the array - * are appended. - * - * @param selectedOptimizations - Current array of selected optimizations. - * @param overrides - Map of experience ID to the desired variant index. - * @returns A new array with overrides applied, or the original array when no overrides exist. - * - * @example - * ```ts - * const result = applyOptimizationOverrides(selectedOptimizations, overrides) - * ``` - * - * @public - */ -export function applyOptimizationOverrides( - selectedOptimizations: SelectedOptimizationArray, - overrides: Map, -): SelectedOptimizationArray { - // Clone only if overrides exist - if (overrides.size === 0) return selectedOptimizations - - const overridden = selectedOptimizations.map((selected) => { - const overrideIndex = overrides.get(selected.experienceId) - return overrideIndex !== undefined ? { ...selected, variantIndex: overrideIndex } : selected - }) - - // Add new overrides not present in selectedOptimizations - for (const [experienceId, variantIndex] of overrides) { - if (!overridden.some((selected) => selected.experienceId === experienceId)) { - overridden.push({ experienceId, variantIndex, variants: {} }) - } - } - - return overridden -} - /** * Merges user-selected variant overrides into the given custom-flag changes. * From a3de31cf2110ef254fc4b6fb37c0d6d5643abb43 Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Tue, 9 Jun 2026 17:56:08 +0200 Subject: [PATCH 2/5] fix [NT-3230]: Sync changes signal on preview override updates --- .../src/attachOptimizationPreviewPanel.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts index 9461795d..31dbea4c 100644 --- a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts +++ b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts @@ -271,6 +271,15 @@ async function attachOptimizationPreviewPanelToSdk): void => { + if (storedDefaults.changes === undefined) return + signals.changes.value = applyChangeOverrides( + storedDefaults.changes, + panel.optimizationEntries, + overridesMap, + ) + } + const manager = new PreviewOverrideManager({ selectedOptimizations: signals.selectedOptimizations, profile: signals.profile, @@ -279,6 +288,7 @@ async function attachOptimizationPreviewPanelToSdk Date: Tue, 9 Jun 2026 19:22:24 +0200 Subject: [PATCH 3/5] chore: updating lock file --- pnpm-lock.yaml | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7b2eb8b..ea50e160 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,7 +209,7 @@ importers: version: 24.10.13 contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) contentful-cli: specifier: ^3.10.4 version: 3.10.4(@types/node@24.10.13)(enquirer@2.3.6)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@8.57.1))(eslint-plugin-n@17.24.0(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-promise@7.2.1(eslint@8.57.1)) @@ -276,7 +276,7 @@ importers: version: link:../../../lib/build-tools contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) ejs: specifier: 'catalog:' version: 3.1.10 @@ -415,7 +415,7 @@ importers: version: link:../../lib/build-tools contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) core-js: specifier: ^3.49.0 version: 3.49.0 @@ -503,7 +503,7 @@ importers: dependencies: contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) zod: specifier: 'catalog:' version: 4.3.6 @@ -546,7 +546,7 @@ importers: version: 1.13.0 contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) es-toolkit: specifier: 'catalog:' version: 1.46.0 @@ -657,7 +657,7 @@ importers: version: link:../../../../lib/build-tools contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) happy-dom: specifier: 'catalog:' version: 20.8.9 @@ -679,6 +679,9 @@ importers: packages/web/preview-panel: dependencies: + '@contentful/optimization-core': + specifier: workspace:* + version: link:../../universal/core-sdk '@contentful/optimization-web': specifier: workspace:* version: link:../web-sdk @@ -690,7 +693,7 @@ importers: version: 1.1.6 contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) es-toolkit: specifier: 'catalog:' version: 1.46.0 @@ -10408,7 +10411,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.16.1: + axios@1.16.1(debug@4.3.7): dependencies: follow-redirects: 1.16.0(debug@4.3.7) form-data: 4.0.5 @@ -11048,11 +11051,11 @@ snapshots: contentful-export@7.22.5: dependencies: - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) bfj: 9.1.3 bluebird: 3.7.2 cli-table3: 0.6.5 - contentful: 11.10.5 + contentful: 11.10.5(debug@4.3.7) contentful-batch-libs: 10.1.6 contentful-management: 11.75.0 date-fns: 4.1.0 @@ -11099,7 +11102,7 @@ snapshots: contentful-management@11.75.0: dependencies: '@contentful/rich-text-types': 16.8.5 - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) contentful-sdk-core: 9.4.5 fast-copy: 3.0.2 globals: 15.15.0 @@ -11110,7 +11113,7 @@ snapshots: contentful-migration@4.32.0(@types/node@24.10.13)(enquirer@2.3.6): dependencies: '@hapi/hoek': 11.0.7 - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) bluebird: 3.7.2 callsites: 3.1.0 cardinal: 2.1.1 @@ -11154,11 +11157,11 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.60.1 - contentful@11.10.5: + contentful@11.10.5(debug@4.3.7): dependencies: '@contentful/content-source-maps': 0.11.44 '@contentful/rich-text-types': 16.8.5 - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) contentful-resolve-response: 1.9.6 contentful-sdk-core: 9.4.4 json-stringify-safe: 5.0.1 From 007fd0a42007a2c76abed8eb2091455fed5caa86 Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Wed, 10 Jun 2026 16:00:11 +0200 Subject: [PATCH 4/5] refactor [NT-3230]: Share applyChangeOverrides via preview-support --- .../applyChangeOverrides.test.ts | 116 ++++++++++ .../preview-support/applyChangeOverrides.ts} | 33 ++- .../core-sdk/src/preview-support/index.ts | 1 + .../src/attachOptimizationPreviewPanel.ts | 212 +++++++----------- .../web/preview-panel/src/lib/schemaGuards.ts | 41 ---- 5 files changed, 226 insertions(+), 177 deletions(-) create mode 100644 packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts rename packages/{web/preview-panel/src/lib/overrides.ts => universal/core-sdk/src/preview-support/applyChangeOverrides.ts} (60%) delete mode 100644 packages/web/preview-panel/src/lib/schemaGuards.ts diff --git a/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts new file mode 100644 index 00000000..f3ef8fdd --- /dev/null +++ b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts @@ -0,0 +1,116 @@ +import { + type ChangeArray, + OptimizationEntry, +} from '@contentful/optimization-api-client/api-schemas' +import { applyChangeOverrides } from './applyChangeOverrides' +import type { OptimizationOverride } from './types' + +// Build OptimizationEntry fixtures via the public Zod schema so we exercise +// applyChangeOverrides against real entry shapes without unsafe type casts. +function buildEntry( + experienceId: string, + key: string, + baseline: unknown, + variant: unknown, +): OptimizationEntry { + return OptimizationEntry.parse({ + metadata: { tags: [], concepts: [] }, + sys: { + space: { sys: { type: 'Link', linkType: 'Space', id: 's' } }, + id: `entry-${experienceId}`, + type: 'Entry', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } }, + publishedVersion: 1, + revision: 1, + contentType: { sys: { type: 'Link', linkType: 'ContentType', id: 'nt_experience' } }, + locale: 'en-US', + }, + fields: { + nt_name: `Flag ${key}`, + nt_type: 'nt_personalization', + nt_experience_id: experienceId, + nt_config: { + components: [ + { + type: 'InlineVariable', + key, + valueType: typeof baseline === 'boolean' ? 'Boolean' : 'Number', + baseline: { value: baseline }, + variants: [{ value: variant }], + }, + ], + distribution: [0, 1], + }, + }, + }) +} + +const ENTRIES: readonly OptimizationEntry[] = [ + buildEntry('exp-1', 'flag-a', false, true), + buildEntry('exp-2', 'flag-b', 0, 42), +] + +const BASELINE_CHANGES: ChangeArray = [ + { + key: 'flag-a', + type: 'Variable', + value: false, + meta: { experienceId: 'exp-1', variantIndex: 0 }, + }, + { key: 'flag-b', type: 'Variable', value: 0, meta: { experienceId: 'exp-2', variantIndex: 0 } }, +] + +const overrides = (record: Record): Record => + Object.fromEntries( + Object.entries(record).map(([experienceId, variantIndex]) => [ + experienceId, + { experienceId, variantIndex }, + ]), + ) + +describe('applyChangeOverrides', () => { + it('returns the input array unchanged when no overrides are provided', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, ENTRIES, {}) + expect(result).toBe(BASELINE_CHANGES) + }) + + it('translates variant overrides into Variable changes for inline-variable components', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, ENTRIES, overrides({ 'exp-1': 1 })) + expect(result.find((c) => c.key === 'flag-a')?.value).toBe(true) + expect(result.find((c) => c.key === 'flag-a')?.meta.variantIndex).toBe(1) + // Untouched override leaves flag-b alone. + expect(result.find((c) => c.key === 'flag-b')?.value).toBe(0) + }) + + it('falls back to baseline value when the override variantIndex points past the variants array', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, ENTRIES, overrides({ 'exp-1': 99 })) + // No variant at index 98 → baseline value re-emitted. + expect(result.find((c) => c.key === 'flag-a')?.value).toBe(false) + }) + + it('emits the baseline value for variantIndex 0', () => { + // Pretend the baseline was already overridden to variant 1 in the baseline-changes + // payload, then drop back to 0 via override. + const baselineWithVariantApplied: ChangeArray = [ + { + key: 'flag-a', + type: 'Variable', + value: true, + meta: { experienceId: 'exp-1', variantIndex: 1 }, + }, + ] + const result = applyChangeOverrides( + baselineWithVariantApplied, + ENTRIES, + overrides({ 'exp-1': 0 }), + ) + expect(result.find((c) => c.key === 'flag-a')?.value).toBe(false) + }) + + it('returns the input unchanged when overrides exist but no entries have inline-variable components', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, [], overrides({ 'exp-1': 1 })) + expect(result).toBe(BASELINE_CHANGES) + }) +}) diff --git a/packages/web/preview-panel/src/lib/overrides.ts b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts similarity index 60% rename from packages/web/preview-panel/src/lib/overrides.ts rename to packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts index b737d9d2..5336698b 100644 --- a/packages/web/preview-panel/src/lib/overrides.ts +++ b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts @@ -1,13 +1,21 @@ import type { ChangeArray, InlineVariableComponent, + OptimizationComponent, OptimizationEntry, -} from '@contentful/optimization-web/api-schemas' -import { isInlineVariableComponent } from './schemaGuards' +} from '@contentful/optimization-api-client/api-schemas' +import type { OptimizationOverride } from './types' + +// Local discriminator-only check. Equivalent to the canonical +// `isInlineVariableComponent` guard but does not pull the rest of the +// api-schemas guard file (which transitively bundles zod) into web UMD builds. +function isInlineVariable(component: OptimizationComponent): component is InlineVariableComponent { + return component.type === 'InlineVariable' +} function getInlineVariableComponents(optimization: OptimizationEntry): InlineVariableComponent[] { const { components } = optimization.fields.nt_config ?? {} - return Array.isArray(components) ? components.filter(isInlineVariableComponent) : [] + return Array.isArray(components) ? components.filter(isInlineVariable) : [] } /** @@ -15,29 +23,32 @@ function getInlineVariableComponents(optimization: OptimizationEntry): InlineVar * * For every overridden experience, any `InlineVariable` components are converted * into `Variable` changes so the runtime flag APIs resolve the selected preview - * value from `changes`. + * value from `changes`. This keeps `getFlag()` consumers in sync with manual + * variant picks made in a preview panel. * - * @param changes - Current array of custom-flag changes. + * @param changes - Current array of custom-flag changes (the un-overridden API baseline). * @param optimizationEntries - Available optimization entries indexed for preview. - * @param overrides - Map of experience ID to the desired variant index. + * @param overrides - Map of experience ID to the desired override. * @returns A new array with inline-variable change overrides applied, or the original array when no overrides exist. * * @public */ export function applyChangeOverrides( changes: ChangeArray, - optimizationEntries: OptimizationEntry[], - overrides: Map, + optimizationEntries: readonly OptimizationEntry[], + overrides: Record, ): ChangeArray { - if (overrides.size === 0) return changes + const overrideValues = Object.values(overrides) + if (overrideValues.length === 0) return changes const overrideChanges = optimizationEntries.flatMap((optimization): ChangeArray => { const { fields: { nt_experience_id: experienceId }, } = optimization - const variantIndex = overrides.get(experienceId) + const { [experienceId]: override } = overrides + if (override === undefined) return [] - if (variantIndex === undefined) return [] + const { variantIndex } = override return getInlineVariableComponents(optimization).map((component): ChangeArray[number] => ({ key: component.key, diff --git a/packages/universal/core-sdk/src/preview-support/index.ts b/packages/universal/core-sdk/src/preview-support/index.ts index 770e8df4..b4255885 100644 --- a/packages/universal/core-sdk/src/preview-support/index.ts +++ b/packages/universal/core-sdk/src/preview-support/index.ts @@ -8,6 +8,7 @@ * @packageDocumentation */ +export { applyChangeOverrides } from './applyChangeOverrides' export { applyOptimizationOverrides } from './applyOptimizationOverrides' export { buildPreviewModel, diff --git a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts index 31dbea4c..d18f7183 100644 --- a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts +++ b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts @@ -1,16 +1,20 @@ -import { PreviewOverrideManager } from '@contentful/optimization-core/preview-support' +import { + PreviewOverrideManager, + applyChangeOverrides, +} from '@contentful/optimization-core/preview-support' import type ContentfulOptimization from '@contentful/optimization-web' import type { AudienceEntrySkeleton, ChangeArray, - OptimizationData, OptimizationEntry, OptimizationEntrySkeleton, Profile, - SelectedOptimizationArray, } from '@contentful/optimization-web/api-schemas' import type { PreviewPanelSignalObject } from '@contentful/optimization-web/core-sdk' -import { PREVIEW_PANEL_SIGNALS_SYMBOL } from '@contentful/optimization-web/symbols' +import { + PREVIEW_PANEL_SIGNALS_SYMBOL, + PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, +} from '@contentful/optimization-web/symbols' import type { ChainModifiers, ContentfulClientApi, Entry } from 'contentful' import { AUDIENCE_SWITCH_CHANGE, @@ -34,8 +38,6 @@ import { } from './components/panel' import { defineSearch } from './components/search' import { getAllEntries, isAudienceEntry, isOptimizationEntry } from './lib/entries' -import { applyChangeOverrides } from './lib/overrides' -import { isChange, isSelectedOptimization } from './lib/schemaGuards' import { createScopedLogger } from './logger' declare global { @@ -49,17 +51,10 @@ declare global { } } -/** @internal */ -let storedDefaults: { - selectedOptimizations?: SelectedOptimizationArray - changes?: ChangeArray -} = {} - /** @internal */ let previewPanelAttachment: Promise | undefined = undefined const OVERRIDES_STORAGE_KEY = '__ctfl_opt_preview_overrides__' -const DEFAULTS_STORAGE_KEY = '__ctfl_opt_preview_defaults__' const storageLogger = createScopedLogger('PreviewPanelStorage') /** @internal */ @@ -88,42 +83,14 @@ function loadOverridesFromStorage(): Map { return overrides } -/** @internal */ -function loadDefaultsFromStorage(): void { - storedDefaults = {} - - try { - const stored = localStorage.getItem(DEFAULTS_STORAGE_KEY) - if (!stored) return - - const parsed = JSON.parse(stored) as unknown - if (!isRecord(parsed)) return - const { selectedOptimizations: storedSelectedOptimizations, changes: storedChanges } = parsed - - storedDefaults.selectedOptimizations = - Array.isArray(storedSelectedOptimizations) && - storedSelectedOptimizations.every(isSelectedOptimization) - ? storedSelectedOptimizations - : undefined - storedDefaults.changes = - Array.isArray(storedChanges) && storedChanges.every(isChange) ? storedChanges : undefined - } catch (error) { - storageLogger.warn(`Failed to read localStorage key "${DEFAULTS_STORAGE_KEY}"`, error) - } -} - -function persistOverrideState(overrides: Map): void { +function persistOverrideMap(overrides: Map): void { try { if (overrides.size === 0) { localStorage.removeItem(OVERRIDES_STORAGE_KEY) - localStorage.removeItem(DEFAULTS_STORAGE_KEY) return } localStorage.setItem(OVERRIDES_STORAGE_KEY, JSON.stringify(Object.fromEntries(overrides))) - if ((storedDefaults.selectedOptimizations ?? storedDefaults.changes) !== undefined) { - localStorage.setItem(DEFAULTS_STORAGE_KEY, JSON.stringify(storedDefaults)) - } } catch (error) { storageLogger.warn('Failed to persist preview panel override state', error) } @@ -158,6 +125,23 @@ function resolveOptimization( return window.contentfulOptimization } +/** + * Convert the manager's selectedOptimizations override record into the + * `Map` shape consumed by the panel UI, the persistence + * helpers, and `applyChangeOverrides`. + * + * @internal + */ +function overridesToMap( + selectedOptimizations: Record, +): Map { + const result = new Map() + for (const { experienceId, variantIndex } of Object.values(selectedOptimizations)) { + result.set(experienceId, variantIndex) + } + return result +} + /** * Configuration for {@link attachOptimizationPreviewPanelToSdk}. * @@ -191,28 +175,12 @@ export interface AttachOptimizationPreviewPanelArgs` shape consumed by the panel UI and `applyChangeOverrides`. - * - * @internal - */ -function overridesToMap( - selectedOptimizations: Record, -): Map { - const result = new Map() - for (const { experienceId, variantIndex } of Object.values(selectedOptimizations)) { - result.set(experienceId, variantIndex) - } - return result -} - /** * Attaches the ContentfulOptimization preview panel to the supplied SDK instance. * * Registers all custom elements, fetches audiences and optimization entries from - * Contentful, wires up state interceptors via the shared - * {@link PreviewOverrideManager}, and appends the panel to `document.body`. + * Contentful, wires up the shared {@link PreviewOverrideManager}, and appends + * the panel to `document.body`. * * @param args - Configuration containing the Contentful client, ContentfulOptimization instance, and optional CSP nonce. * @returns Resolves once the panel has been appended to the document body. @@ -235,14 +203,14 @@ async function attachOptimizationPreviewPanelToSdk isOptimizationEntry(optimization), ) - panel.defaultSelectedOptimizations = [...(storedDefaults.selectedOptimizations ?? [])] panel.overrides = new Map(initialOverrides) + panel.defaultSelectedOptimizations = [] + + // The web panel keeps its own `changes` baseline so it can re-derive + // inline-variable Variable changes whenever an override mutates. The shared + // PreviewOverrideManager only knows about `selectedOptimizations`; mobile + // platforms don't need the changes pipeline today. + let baselineChanges: ChangeArray | null = signals.changes.value ?? null - const syncChangesSignal = (overridesMap: Map): void => { - if (storedDefaults.changes === undefined) return + const syncChangesSignal = ( + overrides: Record, + ): void => { + if (baselineChanges === null) return signals.changes.value = applyChangeOverrides( - storedDefaults.changes, + baselineChanges, panel.optimizationEntries, - overridesMap, + overrides, ) } + // Capture the un-overridden `changes` baseline on every API refresh. Runs + // after the manager's own interceptor (added during `new PreviewOverrideManager`), + // which means manager state is already up to date when this fires. + contentfulOptimization.interceptors.state.add((states) => { + baselineChanges = [...states.changes] + return states + }) + const manager = new PreviewOverrideManager({ selectedOptimizations: signals.selectedOptimizations, profile: signals.profile, @@ -287,34 +271,15 @@ async function attachOptimizationPreviewPanelToSdk { const overridesMap = overridesToMap(state.selectedOptimizations) panel.overrides = new Map(overridesMap) - persistOverrideState(overridesMap) - syncChangesSignal(overridesMap) - }, - }) - - // Web-only interceptor: keep the inline-variable Variable changes in sync - // with overrides. The manager's interceptor runs first and rewrites - // selectedOptimizations to honor overrides; we read the clean baseline back - // from the manager so storedDefaults / panel.defaultSelectedOptimizations - // reflect the un-overridden state. - contentfulOptimization.interceptors.state.add((states): OptimizationData => { - const { changes, ...otherStates } = states - const baseline = manager.getBaselineSelectedOptimizations() ?? states.selectedOptimizations - - storedDefaults = { - selectedOptimizations: [...baseline], - changes: [...changes], - } - panel.defaultSelectedOptimizations = [...baseline] + persistOverrideMap(overridesMap) - const overridesMap = overridesToMap(manager.getOverrides().selectedOptimizations) - if (overridesMap.size > 0) persistOverrideState(overridesMap) + // Keep the panel UI's "default" badges in sync with whatever the manager + // currently considers the un-overridden API baseline. + const baseline = manager.getBaselineSelectedOptimizations() + if (baseline) panel.defaultSelectedOptimizations = [...baseline] - return { - ...otherStates, - selectedOptimizations: states.selectedOptimizations, - changes: applyChangeOverrides(changes, panel.optimizationEntries, overridesMap), - } + syncChangesSignal(state.selectedOptimizations) + }, }) panel.addEventListener(PANEL_DRAWER_TOGGLE, (event: Event) => { @@ -333,7 +298,9 @@ async function attachOptimizationPreviewPanelToSdk { + manager.setVariantOverride(experienceId, variantIndex) + }) }) const onAudienceSwitchReset = (audienceId: string | undefined, experienceIds: string[]): void => { @@ -366,25 +333,31 @@ async function attachOptimizationPreviewPanelToSdk id) - if (event.detail.length === 0) { - onAudienceSwitchReset(audienceId, experienceIds) - return - } + signalFns.batch(() => { + if (event.detail.length === 0) { + onAudienceSwitchReset(audienceId, experienceIds) + return + } - if (!audienceId) { - for (const { key, value } of event.detail) { - manager.setVariantOverride(key, value) + if (!audienceId) { + for (const { key, value } of event.detail) { + manager.setVariantOverride(key, value) + } + return } - return - } - const variantIndex = event.detail[0]?.value ?? 0 - onAudienceSwitchActivate(audienceId, experienceIds, variantIndex) + const variantIndex = event.detail[0]?.value ?? 0 + onAudienceSwitchActivate(audienceId, experienceIds, variantIndex) + }) }) panel.addEventListener(PANEL_RESET, () => { - manager.resetAll() - panel.defaultSelectedOptimizations = [...(storedDefaults.selectedOptimizations ?? [])] + signalFns.batch(() => { + manager.resetAll() + // resetAll wipes overrides; the manager won't repopulate `signals.changes`, + // so do it ourselves with an empty override map (= baseline restored). + syncChangesSignal({}) + }) }) signals.profile.subscribe((profile: Profile | undefined) => { @@ -392,24 +365,13 @@ async function attachOptimizationPreviewPanelToSdk 0) { - for (const [experienceId, variantIndex] of initialOverrides) { - manager.setVariantOverride(experienceId, variantIndex) - } - - // Re-apply change overrides against the cached changes baseline so - // inline-variable flag values reflect the restored selection until the - // next API refresh runs the interceptor chain. - if (storedDefaults.changes) { - signals.changes.value = applyChangeOverrides( - storedDefaults.changes, - panel.optimizationEntries, - initialOverrides, - ) - } + signalFns.batch(() => { + for (const [experienceId, variantIndex] of initialOverrides) { + manager.setVariantOverride(experienceId, variantIndex) + } + }) } document.body.appendChild(panel) @@ -421,7 +383,7 @@ async function attachOptimizationPreviewPanelToSdk { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -export function isSelectedOptimization(value: unknown): value is SelectedOptimizationArray[number] { - return ( - isRecord(value) && - typeof value.experienceId === 'string' && - typeof value.variantIndex === 'number' && - isRecord(value.variants) - ) -} - -export function isChange(value: unknown): value is ChangeArray[number] { - return ( - isRecord(value) && - typeof value.key === 'string' && - typeof value.type === 'string' && - 'value' in value && - isRecord(value.meta) && - typeof value.meta.experienceId === 'string' && - typeof value.meta.variantIndex === 'number' - ) -} - -export function isInlineVariableComponent(value: unknown): value is InlineVariableComponent { - return ( - isRecord(value) && - value.type === 'InlineVariable' && - typeof value.key === 'string' && - isRecord(value.baseline) && - 'value' in value.baseline && - Array.isArray(value.variants) - ) -} From 01b993311e122572ec852ede76d408cb13afabdd Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Fri, 12 Jun 2026 11:53:13 +0200 Subject: [PATCH 5/5] refactor [NT-3230]: Fold changes coordination into PreviewOverrideManager --- packages/universal/api-schemas/package.json | 1 + .../PreviewOverrideManager.changes.test.ts | 98 ++++++++++++++++ .../preview-support/PreviewOverrideManager.ts | 110 +++++++++++++++--- .../preview-support/applyChangeOverrides.ts | 11 +- .../src/attachOptimizationPreviewPanel.ts | 90 ++++---------- 5 files changed, 217 insertions(+), 93 deletions(-) create mode 100644 packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts diff --git a/packages/universal/api-schemas/package.json b/packages/universal/api-schemas/package.json index c5addaa4..9978398a 100644 --- a/packages/universal/api-schemas/package.json +++ b/packages/universal/api-schemas/package.json @@ -24,6 +24,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist/**/*", "THIRD_PARTY_NOTICES.txt" diff --git a/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts new file mode 100644 index 00000000..c1966103 --- /dev/null +++ b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts @@ -0,0 +1,98 @@ +import type { + ChangeArray, + OptimizationData, + OptimizationEntry, + SelectedOptimizationArray, +} from '@contentful/optimization-api-client/api-schemas' +import { signal } from '@preact/signals-core' +import { InterceptorManager } from '../lib/interceptor' +import { PreviewOverrideManager } from './PreviewOverrideManager' +import { + BASELINE, + makeOptimizationData, + type InterceptorFn, +} from './PreviewOverrideManager.test-utils' + +const INITIAL_CHANGES: ChangeArray = [ + { key: 'a', type: 'Variable', value: 1, meta: { experienceId: 'exp-1', variantIndex: 0 } }, + { key: 'b', type: 'Variable', value: 2, meta: { experienceId: 'exp-2', variantIndex: 0 } }, +] + +let selectedOptimizations: ReturnType> +let changes: ReturnType> +let stateInterceptors: InterceptorManager +let capturedInterceptor: InterceptorFn | undefined +let manager: PreviewOverrideManager | undefined + +function createManager( + opts: { withChanges?: boolean; entries?: readonly OptimizationEntry[] } = {}, +): PreviewOverrideManager { + const { withChanges = true, entries = [] } = opts + selectedOptimizations = signal(BASELINE) + changes = signal(INITIAL_CHANGES) + stateInterceptors = new InterceptorManager() + capturedInterceptor = undefined + rs.spyOn(stateInterceptors, 'add').mockImplementation((fn: InterceptorFn) => { + capturedInterceptor = fn + return 1 + }) + rs.spyOn(stateInterceptors, 'remove').mockImplementation(() => true) + manager = new PreviewOverrideManager({ + selectedOptimizations, + changes: withChanges ? changes : undefined, + optimizationEntries: () => entries, + stateInterceptors, + onOverridesChanged: rs.fn(), + }) + return manager +} + +describe('PreviewOverrideManager — changes coordination', () => { + afterEach(() => { + manager?.destroy() + manager = undefined + }) + + it('captures initial changes signal as baseline', () => { + const mgr = createManager() + expect(mgr.getBaselineChanges()).toEqual(INITIAL_CHANGES) + }) + + it('restores changes signal to baseline on resetAll', () => { + const mgr = createManager() + mgr.setVariantOverride('exp-1', 1) + // Manually mutate so we can prove resetAll rewrites the signal value. + changes.value = [] + mgr.resetAll() + expect(changes.value).toEqual(INITIAL_CHANGES) + }) + + it('updates changes baseline on API-refresh interceptor', async () => { + const mgr = createManager() + mgr.setVariantOverride('exp-1', 1) + if (!capturedInterceptor) throw new Error('Interceptor not captured') + + const refreshedChanges: ChangeArray = [ + { key: 'a', type: 'Variable', value: 99, meta: { experienceId: 'exp-1', variantIndex: 0 } }, + ] + await capturedInterceptor({ + ...makeOptimizationData(BASELINE), + changes: refreshedChanges, + }) + + // After the API refresh, the manager treats the new payload as the baseline. + expect(mgr.getBaselineChanges()).toEqual(refreshedChanges) + + // resetAll restores to the *new* baseline, not the original. + mgr.resetAll() + expect(changes.value).toEqual(refreshedChanges) + }) + + it('falls back to single-signal sync when no changes signal is configured (backward-compat)', () => { + const mgr = createManager({ withChanges: false }) + mgr.setVariantOverride('exp-1', 1) + expect(selectedOptimizations.value?.find((s) => s.experienceId === 'exp-1')?.variantIndex).toBe( + 1, + ) + }) +}) diff --git a/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts index 06a4adcb..fc421071 100644 --- a/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts +++ b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts @@ -1,11 +1,14 @@ import type { + ChangeArray, OptimizationData, + OptimizationEntry, Profile, SelectedOptimizationArray, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { Signal } from '@preact/signals-core' +import { batch, type Signal } from '@preact/signals-core' import type { InterceptorManager } from '../lib/interceptor' +import { applyChangeOverrides } from './applyChangeOverrides' import { applyOptimizationOverrides } from './applyOptimizationOverrides' import type { OptimizationOverride, OverrideState } from './types' @@ -45,6 +48,23 @@ export interface PreviewOverrideManagerConfig { */ profile?: Signal + /** + * Optional `changes` signal. When provided alongside {@link optimizationEntries}, + * the manager keeps inline-variable Variable changes in sync with overrides on + * every mutation and on every API refresh — so `getFlag()` consumers see the + * preview-selected variant value without a round-trip. + */ + changes?: Signal + + /** + * Optional provider for the optimization entries the panel knows about. + * Required to coordinate {@link changes}; the manager uses each entry's + * `nt_config.components` to translate variant overrides into Variable changes. + * Implemented as a function so callers can return an up-to-date list as the + * panel fetches additional entries. + */ + optimizationEntries?: () => readonly OptimizationEntry[] + /** The state interceptor registry to register with. */ stateInterceptors: StateInterceptorAccess @@ -73,18 +93,30 @@ export interface PreviewOverrideManagerConfig { */ export class PreviewOverrideManager { private baselineSelectedOptimizations: SelectedOptimizationArray | null = null + private baselineChanges: ChangeArray | null = null private baselineAudienceQualifications: Record = {} private overrides: OverrideState = { ...INITIAL_STATE, audiences: {}, selectedOptimizations: {} } private interceptorId: number | null = null private readonly selectedOptimizations: Signal + private readonly changes: Signal | undefined + private readonly optimizationEntries: (() => readonly OptimizationEntry[]) | undefined private readonly profile: Signal | undefined private readonly stateInterceptors: StateInterceptorAccess private readonly onOverridesChanged: ((state: Readonly) => void) | undefined constructor(config: PreviewOverrideManagerConfig) { - const { selectedOptimizations, profile, stateInterceptors, onOverridesChanged } = config + const { + selectedOptimizations, + changes, + optimizationEntries, + profile, + stateInterceptors, + onOverridesChanged, + } = config this.selectedOptimizations = selectedOptimizations + this.changes = changes + this.optimizationEntries = optimizationEntries this.profile = profile this.stateInterceptors = stateInterceptors this.onOverridesChanged = onOverridesChanged @@ -97,21 +129,28 @@ export class PreviewOverrideManager { logger.debug('Captured initial signal state as baseline') } + if (changes) { + const { value: initialChanges } = changes + if (initialChanges) this.baselineChanges = initialChanges + } + // Register state interceptor to preserve overrides when API responses arrive this.interceptorId = config.stateInterceptors.add( (data: Readonly): OptimizationData => { - // Cache the un-overridden selectedOptimizations as the new baseline - const { selectedOptimizations: incoming } = data - this.baselineSelectedOptimizations = incoming + // Cache the un-overridden state as the new baseline + const { selectedOptimizations: incomingSelected, changes: incomingChanges } = data + this.baselineSelectedOptimizations = incomingSelected + this.baselineChanges = incomingChanges const hasOverrides = Object.keys(this.overrides.selectedOptimizations).length > 0 - const next = hasOverrides + const next: OptimizationData = hasOverrides ? { ...data, selectedOptimizations: applyOptimizationOverrides( data.selectedOptimizations, this.overrides.selectedOptimizations, ), + changes: this.deriveChanges(data.changes), } : { ...data } @@ -233,8 +272,8 @@ export class PreviewOverrideManager { // --------------------------------------------------------------------------- /** - * Clear all overrides and restore the `selectedOptimizations` signal to the - * clean API baseline. + * Clear all overrides and restore the tracked signals to their clean + * API baselines. */ resetAll(): void { logger.info('Resetting all overrides to baseline') @@ -242,11 +281,17 @@ export class PreviewOverrideManager { this.overrides = { audiences: {}, selectedOptimizations: {} } this.baselineAudienceQualifications = {} - // Restore signal to baseline - const { baselineSelectedOptimizations } = this + const { baselineSelectedOptimizations, baselineChanges, changes } = this if (baselineSelectedOptimizations) { - this.selectedOptimizations.value = baselineSelectedOptimizations - logger.debug('Restored signal to baseline') + if (changes && baselineChanges) { + batch(() => { + this.selectedOptimizations.value = baselineSelectedOptimizations + changes.value = baselineChanges + }) + } else { + this.selectedOptimizations.value = baselineSelectedOptimizations + } + logger.debug('Restored signals to baseline') } this.notifyChanged() @@ -266,6 +311,11 @@ export class PreviewOverrideManager { return this.baselineSelectedOptimizations } + /** Returns the cached baseline changes array, or null if no baseline yet. */ + getBaselineChanges(): Readonly | null { + return this.baselineChanges + } + /** * Returns the pre-override audience qualification snapshot — a map from * `audienceId` to whether the user was naturally in that audience (`profile.audiences` @@ -294,6 +344,7 @@ export class PreviewOverrideManager { this.overrides = { audiences: {}, selectedOptimizations: {} } this.baselineSelectedOptimizations = null + this.baselineChanges = null this.baselineAudienceQualifications = {} } @@ -319,15 +370,42 @@ export class PreviewOverrideManager { } /** - * Recompute the signal from the clean API baseline + current overrides. - * Never reads from the current signal — always derives from baseline. + * Recompute the tracked signals from their clean API baselines + current + * overrides. Both writes happen inside one signals batch so subscribers + * never observe a half-updated state. Never reads from the current + * signal values — always derives from the cached baselines. */ private syncOverridesToSignal(): void { - this.selectedOptimizations.value = applyOptimizationOverrides( + const nextSelected = applyOptimizationOverrides( this.baselineSelectedOptimizations ?? [], this.overrides.selectedOptimizations, ) - logger.debug('Synced overrides to signal') + + if (this.changes) { + const nextChanges = this.deriveChanges(this.baselineChanges ?? []) + batch(() => { + this.selectedOptimizations.value = nextSelected + if (this.changes) this.changes.value = nextChanges + }) + } else { + this.selectedOptimizations.value = nextSelected + } + + logger.debug('Synced overrides to signals') + } + + /** + * Translate the current variant overrides into Variable changes and merge + * them into the supplied baseline `changes` array. Returns the input + * unchanged when the manager wasn't given an `optimizationEntries` provider. + */ + private deriveChanges(baseline: ChangeArray): ChangeArray { + if (!this.optimizationEntries) return baseline + return applyChangeOverrides( + baseline, + this.optimizationEntries(), + this.overrides.selectedOptimizations, + ) } private setAudienceOverride( diff --git a/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts index 5336698b..637a1405 100644 --- a/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts +++ b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts @@ -1,21 +1,14 @@ import type { ChangeArray, InlineVariableComponent, - OptimizationComponent, OptimizationEntry, } from '@contentful/optimization-api-client/api-schemas' +import { isInlineVariableComponent } from '@contentful/optimization-api-client/api-schemas' import type { OptimizationOverride } from './types' -// Local discriminator-only check. Equivalent to the canonical -// `isInlineVariableComponent` guard but does not pull the rest of the -// api-schemas guard file (which transitively bundles zod) into web UMD builds. -function isInlineVariable(component: OptimizationComponent): component is InlineVariableComponent { - return component.type === 'InlineVariable' -} - function getInlineVariableComponents(optimization: OptimizationEntry): InlineVariableComponent[] { const { components } = optimization.fields.nt_config ?? {} - return Array.isArray(components) ? components.filter(isInlineVariable) : [] + return Array.isArray(components) ? components.filter(isInlineVariableComponent) : [] } /** diff --git a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts index d18f7183..ca407a1c 100644 --- a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts +++ b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts @@ -1,20 +1,13 @@ -import { - PreviewOverrideManager, - applyChangeOverrides, -} from '@contentful/optimization-core/preview-support' +import { PreviewOverrideManager } from '@contentful/optimization-core/preview-support' import type ContentfulOptimization from '@contentful/optimization-web' import type { AudienceEntrySkeleton, - ChangeArray, OptimizationEntry, OptimizationEntrySkeleton, Profile, } from '@contentful/optimization-web/api-schemas' import type { PreviewPanelSignalObject } from '@contentful/optimization-web/core-sdk' -import { - PREVIEW_PANEL_SIGNALS_SYMBOL, - PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, -} from '@contentful/optimization-web/symbols' +import { PREVIEW_PANEL_SIGNALS_SYMBOL } from '@contentful/optimization-web/symbols' import type { ChainModifiers, ContentfulClientApi, Entry } from 'contentful' import { AUDIENCE_SWITCH_CHANGE, @@ -127,8 +120,8 @@ function resolveOptimization( /** * Convert the manager's selectedOptimizations override record into the - * `Map` shape consumed by the panel UI, the persistence - * helpers, and `applyChangeOverrides`. + * `Map` shape consumed by the panel UI and the localStorage + * persistence helpers. * * @internal */ @@ -203,9 +196,8 @@ async function attachOptimizationPreviewPanelToSdk, - ): void => { - if (baselineChanges === null) return - signals.changes.value = applyChangeOverrides( - baselineChanges, - panel.optimizationEntries, - overrides, - ) - } - - // Capture the un-overridden `changes` baseline on every API refresh. Runs - // after the manager's own interceptor (added during `new PreviewOverrideManager`), - // which means manager state is already up to date when this fires. - contentfulOptimization.interceptors.state.add((states) => { - baselineChanges = [...states.changes] - return states - }) - const manager = new PreviewOverrideManager({ selectedOptimizations: signals.selectedOptimizations, + changes: signals.changes, + optimizationEntries: () => panel.optimizationEntries, profile: signals.profile, stateInterceptors: contentfulOptimization.interceptors.state, onOverridesChanged: (state) => { @@ -277,8 +246,6 @@ async function attachOptimizationPreviewPanelToSdk { - manager.setVariantOverride(experienceId, variantIndex) - }) + manager.setVariantOverride(experienceId, variantIndex) }) const onAudienceSwitchReset = (audienceId: string | undefined, experienceIds: string[]): void => { @@ -333,31 +298,24 @@ async function attachOptimizationPreviewPanelToSdk id) - signalFns.batch(() => { - if (event.detail.length === 0) { - onAudienceSwitchReset(audienceId, experienceIds) - return - } + if (event.detail.length === 0) { + onAudienceSwitchReset(audienceId, experienceIds) + return + } - if (!audienceId) { - for (const { key, value } of event.detail) { - manager.setVariantOverride(key, value) - } - return + if (!audienceId) { + for (const { key, value } of event.detail) { + manager.setVariantOverride(key, value) } + return + } - const variantIndex = event.detail[0]?.value ?? 0 - onAudienceSwitchActivate(audienceId, experienceIds, variantIndex) - }) + const variantIndex = event.detail[0]?.value ?? 0 + onAudienceSwitchActivate(audienceId, experienceIds, variantIndex) }) panel.addEventListener(PANEL_RESET, () => { - signalFns.batch(() => { - manager.resetAll() - // resetAll wipes overrides; the manager won't repopulate `signals.changes`, - // so do it ourselves with an empty override map (= baseline restored). - syncChangesSignal({}) - }) + manager.resetAll() }) signals.profile.subscribe((profile: Profile | undefined) => { @@ -366,12 +324,8 @@ async function attachOptimizationPreviewPanelToSdk 0) { - signalFns.batch(() => { - for (const [experienceId, variantIndex] of initialOverrides) { - manager.setVariantOverride(experienceId, variantIndex) - } - }) + for (const [experienceId, variantIndex] of initialOverrides) { + manager.setVariantOverride(experienceId, variantIndex) } document.body.appendChild(panel)