Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/universal/api-schemas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist/**/*",
"THIRD_PARTY_NOTICES.txt"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof signal<SelectedOptimizationArray | undefined>>
let changes: ReturnType<typeof signal<ChangeArray | undefined>>
let stateInterceptors: InterceptorManager<OptimizationData>
let capturedInterceptor: InterceptorFn | undefined
let manager: PreviewOverrideManager | undefined

function createManager(
opts: { withChanges?: boolean; entries?: readonly OptimizationEntry[] } = {},
): PreviewOverrideManager {
const { withChanges = true, entries = [] } = opts
selectedOptimizations = signal<SelectedOptimizationArray | undefined>(BASELINE)
changes = signal<ChangeArray | undefined>(INITIAL_CHANGES)
stateInterceptors = new InterceptorManager<OptimizationData>()
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,
)
})
})
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -45,6 +48,23 @@ export interface PreviewOverrideManagerConfig {
*/
profile?: Signal<Profile | undefined>

/**
* 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<ChangeArray | undefined>

/**
* 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<OptimizationData>

Expand Down Expand Up @@ -73,18 +93,30 @@ export interface PreviewOverrideManagerConfig {
*/
export class PreviewOverrideManager {
private baselineSelectedOptimizations: SelectedOptimizationArray | null = null
private baselineChanges: ChangeArray | null = null
private baselineAudienceQualifications: Record<string, boolean> = {}
private overrides: OverrideState = { ...INITIAL_STATE, audiences: {}, selectedOptimizations: {} }
private interceptorId: number | null = null

private readonly selectedOptimizations: Signal<SelectedOptimizationArray | undefined>
private readonly changes: Signal<ChangeArray | undefined> | undefined
private readonly optimizationEntries: (() => readonly OptimizationEntry[]) | undefined
private readonly profile: Signal<Profile | undefined> | undefined
private readonly stateInterceptors: StateInterceptorAccess<OptimizationData>
private readonly onOverridesChanged: ((state: Readonly<OverrideState>) => 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
Expand All @@ -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>): 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 }

Expand Down Expand Up @@ -233,20 +272,26 @@ 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')

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()
Expand All @@ -266,6 +311,11 @@ export class PreviewOverrideManager {
return this.baselineSelectedOptimizations
}

/** Returns the cached baseline changes array, or null if no baseline yet. */
getBaselineChanges(): Readonly<ChangeArray> | 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`
Expand Down Expand Up @@ -294,6 +344,7 @@ export class PreviewOverrideManager {

this.overrides = { audiences: {}, selectedOptimizations: {} }
this.baselineSelectedOptimizations = null
this.baselineChanges = null
this.baselineAudienceQualifications = {}
}

Expand All @@ -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(
Expand Down
Loading
Loading