11import type {
2+ ChangeArray ,
23 OptimizationData ,
4+ OptimizationEntry ,
35 Profile ,
46 SelectedOptimizationArray ,
57} from '@contentful/optimization-api-client/api-schemas'
68import { createScopedLogger } from '@contentful/optimization-api-client/logger'
7- import type { Signal } from '@preact/signals-core'
9+ import { batch , type Signal } from '@preact/signals-core'
810import type { InterceptorManager } from '../lib/interceptor'
11+ import { applyChangeOverrides } from './applyChangeOverrides'
912import { applyOptimizationOverrides } from './applyOptimizationOverrides'
1013import type { OptimizationOverride , OverrideState } from './types'
1114
@@ -45,6 +48,23 @@ export interface PreviewOverrideManagerConfig {
4548 */
4649 profile ?: Signal < Profile | undefined >
4750
51+ /**
52+ * Optional `changes` signal. When provided alongside {@link optimizationEntries},
53+ * the manager keeps inline-variable Variable changes in sync with overrides on
54+ * every mutation and on every API refresh — so `getFlag()` consumers see the
55+ * preview-selected variant value without a round-trip.
56+ */
57+ changes ?: Signal < ChangeArray | undefined >
58+
59+ /**
60+ * Optional provider for the optimization entries the panel knows about.
61+ * Required to coordinate {@link changes}; the manager uses each entry's
62+ * `nt_config.components` to translate variant overrides into Variable changes.
63+ * Implemented as a function so callers can return an up-to-date list as the
64+ * panel fetches additional entries.
65+ */
66+ optimizationEntries ?: ( ) => readonly OptimizationEntry [ ]
67+
4868 /** The state interceptor registry to register with. */
4969 stateInterceptors : StateInterceptorAccess < OptimizationData >
5070
@@ -73,18 +93,30 @@ export interface PreviewOverrideManagerConfig {
7393 */
7494export class PreviewOverrideManager {
7595 private baselineSelectedOptimizations : SelectedOptimizationArray | null = null
96+ private baselineChanges : ChangeArray | null = null
7697 private baselineAudienceQualifications : Record < string , boolean > = { }
7798 private overrides : OverrideState = { ...INITIAL_STATE , audiences : { } , selectedOptimizations : { } }
7899 private interceptorId : number | null = null
79100
80101 private readonly selectedOptimizations : Signal < SelectedOptimizationArray | undefined >
102+ private readonly changes : Signal < ChangeArray | undefined > | undefined
103+ private readonly optimizationEntries : ( ( ) => readonly OptimizationEntry [ ] ) | undefined
81104 private readonly profile : Signal < Profile | undefined > | undefined
82105 private readonly stateInterceptors : StateInterceptorAccess < OptimizationData >
83106 private readonly onOverridesChanged : ( ( state : Readonly < OverrideState > ) => void ) | undefined
84107
85108 constructor ( config : PreviewOverrideManagerConfig ) {
86- const { selectedOptimizations, profile, stateInterceptors, onOverridesChanged } = config
109+ const {
110+ selectedOptimizations,
111+ changes,
112+ optimizationEntries,
113+ profile,
114+ stateInterceptors,
115+ onOverridesChanged,
116+ } = config
87117 this . selectedOptimizations = selectedOptimizations
118+ this . changes = changes
119+ this . optimizationEntries = optimizationEntries
88120 this . profile = profile
89121 this . stateInterceptors = stateInterceptors
90122 this . onOverridesChanged = onOverridesChanged
@@ -97,21 +129,28 @@ export class PreviewOverrideManager {
97129 logger . debug ( 'Captured initial signal state as baseline' )
98130 }
99131
132+ if ( changes ) {
133+ const { value : initialChanges } = changes
134+ if ( initialChanges ) this . baselineChanges = initialChanges
135+ }
136+
100137 // Register state interceptor to preserve overrides when API responses arrive
101138 this . interceptorId = config . stateInterceptors . add (
102139 ( data : Readonly < OptimizationData > ) : OptimizationData => {
103- // Cache the un-overridden selectedOptimizations as the new baseline
104- const { selectedOptimizations : incoming } = data
105- this . baselineSelectedOptimizations = incoming
140+ // Cache the un-overridden state as the new baseline
141+ const { selectedOptimizations : incomingSelected , changes : incomingChanges } = data
142+ this . baselineSelectedOptimizations = incomingSelected
143+ this . baselineChanges = incomingChanges
106144
107145 const hasOverrides = Object . keys ( this . overrides . selectedOptimizations ) . length > 0
108- const next = hasOverrides
146+ const next : OptimizationData = hasOverrides
109147 ? {
110148 ...data ,
111149 selectedOptimizations : applyOptimizationOverrides (
112150 data . selectedOptimizations ,
113151 this . overrides . selectedOptimizations ,
114152 ) ,
153+ changes : this . deriveChanges ( data . changes ) ,
115154 }
116155 : { ...data }
117156
@@ -233,20 +272,26 @@ export class PreviewOverrideManager {
233272 // ---------------------------------------------------------------------------
234273
235274 /**
236- * Clear all overrides and restore the `selectedOptimizations` signal to the
237- * clean API baseline .
275+ * Clear all overrides and restore the tracked signals to their clean
276+ * API baselines .
238277 */
239278 resetAll ( ) : void {
240279 logger . info ( 'Resetting all overrides to baseline' )
241280
242281 this . overrides = { audiences : { } , selectedOptimizations : { } }
243282 this . baselineAudienceQualifications = { }
244283
245- // Restore signal to baseline
246- const { baselineSelectedOptimizations } = this
284+ const { baselineSelectedOptimizations, baselineChanges, changes } = this
247285 if ( baselineSelectedOptimizations ) {
248- this . selectedOptimizations . value = baselineSelectedOptimizations
249- logger . debug ( 'Restored signal to baseline' )
286+ if ( changes && baselineChanges ) {
287+ batch ( ( ) => {
288+ this . selectedOptimizations . value = baselineSelectedOptimizations
289+ changes . value = baselineChanges
290+ } )
291+ } else {
292+ this . selectedOptimizations . value = baselineSelectedOptimizations
293+ }
294+ logger . debug ( 'Restored signals to baseline' )
250295 }
251296
252297 this . notifyChanged ( )
@@ -266,6 +311,11 @@ export class PreviewOverrideManager {
266311 return this . baselineSelectedOptimizations
267312 }
268313
314+ /** Returns the cached baseline changes array, or null if no baseline yet. */
315+ getBaselineChanges ( ) : Readonly < ChangeArray > | null {
316+ return this . baselineChanges
317+ }
318+
269319 /**
270320 * Returns the pre-override audience qualification snapshot — a map from
271321 * `audienceId` to whether the user was naturally in that audience (`profile.audiences`
@@ -294,6 +344,7 @@ export class PreviewOverrideManager {
294344
295345 this . overrides = { audiences : { } , selectedOptimizations : { } }
296346 this . baselineSelectedOptimizations = null
347+ this . baselineChanges = null
297348 this . baselineAudienceQualifications = { }
298349 }
299350
@@ -319,15 +370,42 @@ export class PreviewOverrideManager {
319370 }
320371
321372 /**
322- * Recompute the signal from the clean API baseline + current overrides.
323- * Never reads from the current signal — always derives from baseline.
373+ * Recompute the tracked signals from their clean API baselines + current
374+ * overrides. Both writes happen inside one signals batch so subscribers
375+ * never observe a half-updated state. Never reads from the current
376+ * signal values — always derives from the cached baselines.
324377 */
325378 private syncOverridesToSignal ( ) : void {
326- this . selectedOptimizations . value = applyOptimizationOverrides (
379+ const nextSelected = applyOptimizationOverrides (
327380 this . baselineSelectedOptimizations ?? [ ] ,
328381 this . overrides . selectedOptimizations ,
329382 )
330- logger . debug ( 'Synced overrides to signal' )
383+
384+ if ( this . changes ) {
385+ const nextChanges = this . deriveChanges ( this . baselineChanges ?? [ ] )
386+ batch ( ( ) => {
387+ this . selectedOptimizations . value = nextSelected
388+ if ( this . changes ) this . changes . value = nextChanges
389+ } )
390+ } else {
391+ this . selectedOptimizations . value = nextSelected
392+ }
393+
394+ logger . debug ( 'Synced overrides to signals' )
395+ }
396+
397+ /**
398+ * Translate the current variant overrides into Variable changes and merge
399+ * them into the supplied baseline `changes` array. Returns the input
400+ * unchanged when the manager wasn't given an `optimizationEntries` provider.
401+ */
402+ private deriveChanges ( baseline : ChangeArray ) : ChangeArray {
403+ if ( ! this . optimizationEntries ) return baseline
404+ return applyChangeOverrides (
405+ baseline ,
406+ this . optimizationEntries ( ) ,
407+ this . overrides . selectedOptimizations ,
408+ )
331409 }
332410
333411 private setAudienceOverride (
0 commit comments