Skip to content

PlacementController

Framework-agnostic placement lifecycle controller.

Encapsulates the same register → decide → impression-track → interact flow that the React usePlacement() hook provides, but without any framework dependency.

Subscribe to state changes with onChange and read the current state with state, or just await load() for a one-shot pattern.

TypeScript
const ctrl = new PlacementController(sdk, { surfaceSlot: { id: 'upsell_banner' } });
ctrl.onChange(() => updateUI(ctrl.state));
await ctrl.load();

get content(): RevTurbineDecisionContent | null

Current resolved content, or null if no decision has been loaded.

RevTurbineDecisionContent | null


get decision(): RevTurbinePlacementDecision | null

Current decision, or null if not yet loaded.

RevTurbinePlacementDecision | null


get placementId(): string

Registered placement ID, or empty string before first load().

string


get state(): PlacementControllerState

Current state snapshot.

PlacementControllerState


get visible(): boolean

Convenience: true when the current decision says the placement is visible.

boolean

ctaClick(ctaTarget?): Promise<void>

Record a CTA click interaction.

string

Promise<void>


ctaComplete(ctaTarget?): Promise<void>

Record a CTA completion interaction and hide the placement.

string

Promise<void>


dismiss(cooldownMs?): Promise<void>

Record a dismissal.

number

Optional explicit window. Omit it — which is the normal case — and the SDK resolves the payload’s authored caps.cooldown_days, falling back to the 7-day default. This used to default to 24h here and pass it ALWAYS, so the authored value and the 7-day default were both unreachable from React (plan 233 TASK-8b).

Promise<void>


dispose(): void

Stop listening for Playbook loads and user-context changes. Idempotent; call it when the owning component unmounts (usePlacement does).

void


load(): Promise<RevTurbinePlacementDecision | null>

Register the placement (if needed) and fetch a decision.

When autoTrackImpression is true (default), a visible decision automatically records an impression event.

Promise<RevTurbinePlacementDecision | null>


markRendered(): void

Called when the placement’s visual root renders (plan 144 TASK-11). Emits placement_rendered once, and under placementExposure: 'render' credits the presentation now. Idempotent.

void


markVisible(basis?): void

Called by the viewport-exposure substrate when the placement’s visual root first enters the viewport — or immediately with 'render_fallback' when IntersectionObserver is unavailable (plan 144 TASK-9/11 / AC-9, AC-10). Emits placement_exposed once with the basis, and under placementExposure: 'viewport' a viewport exposure credits the presentation here (Q-8). Idempotent.

ExposureBasis = 'viewport'

how exposure was established; defaults to 'viewport'

void


onChange(listener): () => void

Subscribe to state changes. Returns an unsubscribe function.

ChangeListener

() => void

TypeScript
const unsub = ctrl.onChange(() => console.log(ctrl.state));
// later...
unsub();

refresh(): Promise<RevTurbinePlacementDecision | null>

Re-fetch the placement decision (clears impression + render + exposure tracking).

Promise<RevTurbinePlacementDecision | null>


remindMeLater(seconds?): Promise<void>

Alias for snooze.

number

Promise<void>


reset(): void

Reset the controller state (clears decision, placement ID, etc.).

void


snooze(seconds?): Promise<void>

Record a snooze / remind-me-later.

number

Optional explicit window. Omit it and the SDK resolves the payload’s authored remind_later_minutes, falling back to the tenant default (plan 233 TASK-8c). Remind-later is a DIFFERENT window from dismiss and resolves independently.

Promise<void>


watchUserContext(): () => void

Re-decide whenever the user context changes (BL-0004).

The counterpart to EntitlementGate.watchUserContext, and the reason sdk.convert() now removes a converted placement in place: conversion writes no suppression — eligibility is a function of the user’s CURRENT plan and targeting — so the only thing that can take a converted upgrade banner off screen is a re-decide against the reloaded context. Without this the placement stayed mounted until something else happened to re-resolve it, and the host had to call refresh() itself or use the component’s ctaComplete() (the limitation plan 236 TASK-13 recorded).

Only re-decides a controller that has already resolved something: before the first load() there is nothing on screen to be stale, and firing then would turn every startup identify() into a duplicate decision.

Calls load() rather than refresh() on purpose — refresh() clears impression/exposure tracking, so a context change would re-credit a presentation the user never saw again.

Returns an unsubscribe function; dispose also drops it.

() => void