Patterns: Managers
Singleton manager pattern: per-
botDivIdregistry, factory methods,createRootmount points, and the duplicated boilerplate betweenCsatManagerandRatingFormManager. Load via thepatterns-architecturecontext group.
Why managers exist
A single page can host multiple webchat widgets, each identified by a unique botDivId. Per-feature state (CSAT lifecycle, rating form lifecycle, notifications, history, pre-chat) needs to be scoped per widget. Managers are the unit of that scoping.
There are five managers today, three of which use the registry pattern, two of which do not:
| Manager | File | Registry? | Renders React? |
|---|---|---|---|
CsatManager | src/csat/csatManager.tsx | Yes — Map<botDivId, CsatManager> | Yes (createRoot → <CustomCSATPopup>) |
RatingFormManager | src/ratingForm/ratingFormManager.tsx | Yes — Map<botDivId, RatingFormManager> | Yes (createRoot → <CustomRatingFormPopup>) |
HistoryManager | src/history/historyManager.ts | Yes — Map<id, HistoryManager> (keyed by bot config id, not botDivId) | No |
NotificationManager | src/notifications/notificationManager.tsx | No — instantiated per CoreWidget.setUpWebchat() | Yes (createRoot → <CustomNotifications>) |
PreChatManager | src/preChatSteps/preChatStepsManager.ts | No — instantiated inline in BubbleWidget.setUpBubbleWebchat() | No (uses imperative outerHTML) |
The registry pattern
For new popup-style managers (CSAT, rating form, future overlays), mirror the CsatManager / RatingFormManager shape:
export default class FooManager {
private static instances: Map<string, FooManager> = new Map();
private constructor(
private botPreferences: IDefaultProperties,
private widget: CoreWidget,
private fooProps: IFooProps,
) {}
public static getInstance(
botPreferences?: IDefaultProperties,
widget?: CoreWidget,
fooProps?: IFooProps,
): FooManager {
const botDivId = botPreferences?.botDivId;
if (!botDivId) {
throw new Error("botDivId is required to create a FooManager");
}
if (!FooManager.instances.has(botDivId)) {
const instance = new FooManager(botPreferences!, widget!, fooProps!);
FooManager.instances.set(botDivId, instance);
}
return FooManager.instances.get(botDivId)!;
}
public static getInstanceById(botDivId: string): FooManager | undefined {
return FooManager.instances.get(botDivId);
}
public static removeInstance(botDivId: string): void {
if (FooManager.instances.has(botDivId)) {
FooManager.instances.delete(botDivId);
}
}
public static removeAllInstances(): void {
FooManager.instances = new Map();
}
public remove(): void {
FooManager.removeInstance(this.botPreferences.botDivId);
}
public createFooContainer(): void {
const span = document.createElement("span");
span.className = "webchat__foo__container";
const transcript = document.querySelector(/* transcript selector */);
transcript?.appendChild(span);
createRoot(span).render(<FooPopup foo={this.fooProps} manager={this} />);
}
public dismissPopup(): void {
const containers = document.getElementsByClassName("webchat__foo__container");
if (containers.length > 0) containers[0].remove();
this.remove();
}
public dispatchResponse(payload: IFooPayload): void {
this.widget.instanceStore.dispatch({
type: WEBCHAT.SEND_POST_BACK,
payload: { ...payload, sessionId: this.widget.webStorageInstance.get("sessionId") },
});
this.dismissPopup();
}
public translate(key: string): string {
return this.widget.localeManager.translate(key);
}
}
Key points:
- The class is the registry.
private static instances: Map<string, T>is the source of truth. getInstance()is the only public constructor path. Directnew FooManager(...)is private and not meant to be called externally.getInstanceaccepts optional args because callers from cleanup paths sometimes have only thebotDivId— but the first call must pass real values or the manager throws.- The mount-point class name follows the BEM rule:
webchat__*if the manager is rendering a botframework-webchat-styled container,hbf-*for purely Helvia-internal containers (seestyling.md).
When to add a new manager vs hook into an existing one
Add a new manager when:
- The feature has a lifecycle independent of the message stream (popup that can be opened/dismissed by external code, scheduling that runs on its own timer).
- The feature needs per-
botDivIdstate that survives across activities. - The feature is invoked from outside the React component tree (from a webchat middleware, from the
CoreWidget, from the publicwindow.HBFWebchatAPI).
Hook into an existing manager when:
- The feature is a variant of an existing one (a new notification level → extend
NotificationManager, not a new manager). - The feature is purely visual and triggered by a single activity type (a new decorator → middleware + React component, no manager needed).
Do not introduce a manager just to hold state for a single React component — use useState or a co-located helper instead.
Duplicated Boilerplate (CSAT / RatingForm)
CsatManager and RatingFormManager are structurally identical except for the domain object and the rendered React component. Both share the same registry implementation, the same factory signatures, and even the same DOM mount class name (webchat__csat__container).
CsatManager.tsx RatingFormManager.tsx
instances field :18 :18
constructor (botPreferences, widget, (botPreferences, widget,
csatValue: CSATValue) ratingFormProps: RatingFormProps)
getInstance :34–50 :34–50
getInstanceById :55–57 :55–57
removeInstance :66–70 :66–70
removeAllInstances :59–61 :59–61
remove (instance) :75–77 :75–77
createXContainer createCsatContainer (line ~80) createRatingFormContainer (line ~80)
dismissPopup dismisses webchat__csat__ dismisses webchat__csat__
container[0] container[0] ← same selector!
The shared mount class name means only one of the two popups can be visible at a time. Both dismissPopup() implementations target getElementsByClassName("webchat__csat__container")[0]; this works in practice because the two popups are mutually exclusive in the bot flow, but it is fragile.
A future refactor would extract BasePopupManager<TConfig, TPopupComponent> with the registry/factory pattern in the base and only the domain-specific render call in the subclass. Do not refactor as part of an unrelated change. When adding a third popup manager, that is the right time to extract the base.
The duplication is flagged in anti-patterns.md for visibility but is not actively tracked in any migration task.
Manager-Specific Notes
NotificationManager (no registry)
Constructed inside CoreWidget.setUpWebchat() and stored on the CoreWidget instance. There is one NotificationManager per widget; it does not need a Map<botDivId, ...> because it lives on the CoreWidget that already scopes per botDivId.
To add a new notification level: extend the NotificationLevels enum in src/interfaces/CustomNotificationInterfaces.ts, then add a branch in NotificationManager.toToastOptions() returning a ToastOptions for the new level. Currently only INFO has a styled branch; ERROR, SUCCESS, and WARN fall through to the same default.
PreChatManager (no registry, no React)
Owned by BubbleWidget. Renders all step UI imperatively via outerHTML/innerHTML template strings — it predates the createRoot bridge pattern and has not been migrated. Currently supports exactly two hard-coded steps in renderCurrentStep()'s switch.
To add a third step: extend the switch with case 3:, raise the cap in nextStep(), add a hasThirdStep() guard mirroring hasSecondStep(), and update setupEventDelegation() for the new data-action. A more invasive refactor would replace renderCurrentStep() with createRoot(...).render(<StepComponent />) to align with the rest of the codebase.
HistoryManager (registry by id, not botDivId)
Keyed by the bot config id (which can be shared across widgets pointing at the same bot). Manages activity persistence in WebStorage with size, session, and message limits. Used by CoreWidget.setUpWebchat() to restore history on reload.
LocaleManager (no registry)
Per-CoreWidget instance. Loads the active locale's JSON file via dynamic import() and exposes translate(key). Throws on missing keys at runtime — see styling.md "Translations and Locale" section for the implication on adding new keys.