Skip to content

Commit 0c51ee6

Browse files
authored
refactor(preview): Use shared Preview Module in Web Preview Panel (#314)
1 parent 536d98f commit 0c51ee6

11 files changed

Lines changed: 493 additions & 312 deletions

File tree

packages/universal/api-schemas/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
"./package.json": "./package.json"
2626
},
27+
"sideEffects": false,
2728
"files": [
2829
"dist/**/*",
2930
"THIRD_PARTY_NOTICES.txt"
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type {
2+
ChangeArray,
3+
OptimizationData,
4+
OptimizationEntry,
5+
SelectedOptimizationArray,
6+
} from '@contentful/optimization-api-client/api-schemas'
7+
import { signal } from '@preact/signals-core'
8+
import { InterceptorManager } from '../lib/interceptor'
9+
import { PreviewOverrideManager } from './PreviewOverrideManager'
10+
import {
11+
BASELINE,
12+
makeOptimizationData,
13+
type InterceptorFn,
14+
} from './PreviewOverrideManager.test-utils'
15+
16+
const INITIAL_CHANGES: ChangeArray = [
17+
{ key: 'a', type: 'Variable', value: 1, meta: { experienceId: 'exp-1', variantIndex: 0 } },
18+
{ key: 'b', type: 'Variable', value: 2, meta: { experienceId: 'exp-2', variantIndex: 0 } },
19+
]
20+
21+
let selectedOptimizations: ReturnType<typeof signal<SelectedOptimizationArray | undefined>>
22+
let changes: ReturnType<typeof signal<ChangeArray | undefined>>
23+
let stateInterceptors: InterceptorManager<OptimizationData>
24+
let capturedInterceptor: InterceptorFn | undefined
25+
let manager: PreviewOverrideManager | undefined
26+
27+
function createManager(
28+
opts: { withChanges?: boolean; entries?: readonly OptimizationEntry[] } = {},
29+
): PreviewOverrideManager {
30+
const { withChanges = true, entries = [] } = opts
31+
selectedOptimizations = signal<SelectedOptimizationArray | undefined>(BASELINE)
32+
changes = signal<ChangeArray | undefined>(INITIAL_CHANGES)
33+
stateInterceptors = new InterceptorManager<OptimizationData>()
34+
capturedInterceptor = undefined
35+
rs.spyOn(stateInterceptors, 'add').mockImplementation((fn: InterceptorFn) => {
36+
capturedInterceptor = fn
37+
return 1
38+
})
39+
rs.spyOn(stateInterceptors, 'remove').mockImplementation(() => true)
40+
manager = new PreviewOverrideManager({
41+
selectedOptimizations,
42+
changes: withChanges ? changes : undefined,
43+
optimizationEntries: () => entries,
44+
stateInterceptors,
45+
onOverridesChanged: rs.fn(),
46+
})
47+
return manager
48+
}
49+
50+
describe('PreviewOverrideManager — changes coordination', () => {
51+
afterEach(() => {
52+
manager?.destroy()
53+
manager = undefined
54+
})
55+
56+
it('captures initial changes signal as baseline', () => {
57+
const mgr = createManager()
58+
expect(mgr.getBaselineChanges()).toEqual(INITIAL_CHANGES)
59+
})
60+
61+
it('restores changes signal to baseline on resetAll', () => {
62+
const mgr = createManager()
63+
mgr.setVariantOverride('exp-1', 1)
64+
// Manually mutate so we can prove resetAll rewrites the signal value.
65+
changes.value = []
66+
mgr.resetAll()
67+
expect(changes.value).toEqual(INITIAL_CHANGES)
68+
})
69+
70+
it('updates changes baseline on API-refresh interceptor', async () => {
71+
const mgr = createManager()
72+
mgr.setVariantOverride('exp-1', 1)
73+
if (!capturedInterceptor) throw new Error('Interceptor not captured')
74+
75+
const refreshedChanges: ChangeArray = [
76+
{ key: 'a', type: 'Variable', value: 99, meta: { experienceId: 'exp-1', variantIndex: 0 } },
77+
]
78+
await capturedInterceptor({
79+
...makeOptimizationData(BASELINE),
80+
changes: refreshedChanges,
81+
})
82+
83+
// After the API refresh, the manager treats the new payload as the baseline.
84+
expect(mgr.getBaselineChanges()).toEqual(refreshedChanges)
85+
86+
// resetAll restores to the *new* baseline, not the original.
87+
mgr.resetAll()
88+
expect(changes.value).toEqual(refreshedChanges)
89+
})
90+
91+
it('falls back to single-signal sync when no changes signal is configured (backward-compat)', () => {
92+
const mgr = createManager({ withChanges: false })
93+
mgr.setVariantOverride('exp-1', 1)
94+
expect(selectedOptimizations.value?.find((s) => s.experienceId === 'exp-1')?.variantIndex).toBe(
95+
1,
96+
)
97+
})
98+
})

packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type {
2+
ChangeArray,
23
OptimizationData,
4+
OptimizationEntry,
35
Profile,
46
SelectedOptimizationArray,
57
} from '@contentful/optimization-api-client/api-schemas'
68
import { createScopedLogger } from '@contentful/optimization-api-client/logger'
7-
import type { Signal } from '@preact/signals-core'
9+
import { batch, type Signal } from '@preact/signals-core'
810
import type { InterceptorManager } from '../lib/interceptor'
11+
import { applyChangeOverrides } from './applyChangeOverrides'
912
import { applyOptimizationOverrides } from './applyOptimizationOverrides'
1013
import 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
*/
7494
export 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

Comments
 (0)