Anti-patterns
These are mistakes that look reasonable, compile fine, and cause real bugs that are hard to diagnose after the fact. Read this page before shipping a Plugster you intend to keep — most of these have at least one entry in a real codebase's history, and each one tends to surface as "the framework is broken" when the cause is a violated contract.
Every entry follows the same shape: the mistake, why it bites, and the right way to do it instead. Sections are grouped by the area they touch — outlets, lifecycle, events, list outlets, localization, framework boundaries.
Outlet access #
Calling $() or document.querySelector inside a Plugster method
Bypasses outlet binding, couples the controller to CSS selectors, and breaks tests that render minimal HTML. The framework's contract is that all DOM access goes through this._.<outletName>. If a piece of HTML matters to a controller, give it a data-outlet-id and reach it through this._; if it does not, the controller should not be reaching for it.
Using raw DOM mutation APIs (innerHTML, createElement, appendChild)
Directly writing element.innerHTML = ... or appending nodes bypasses outlet binding entirely. Content injected this way is invisible to the outlet system and cannot be managed by the list-outlet API. For repeatable items use buildListItem; for single-slot regions assign jQuery-rendered fragments through the existing outlet handle.
Hardcoding outlet names as DOM selectors in child-template callbacks
Inside buildListItem callbacks, using $('[data-child-outlet-id="chipText"]') instead of the scoped outlets handle returned by buildListItem. The scoped handle (outlets.chipText) addresses only the current row's element; the selector approach addresses every matching element across the document and silently mutates them all.
Lifecycle #
Wiring outlet events outside afterInit()
Outlets are not bound until init() completes. Wiring in the constructor silently attaches handlers to empty stubs — nothing throws, but nothing fires either. afterInit() is the only safe hook.
Calling Plugster.plug() before await instance.init()
Registering an uninitialized Plugster means outlets are not bound when other Plugsters' HTML subscriptions try to wire against this one. Events that fire immediately will manipulate unbound outlets. The boot pattern exists specifically to enforce the ordering: await new MyPlugster(...).init(), then Plugster.plug(...).
Forgetting Plugster.plug(instance) after init()
The instance is never registered. Queued events targeting it are never flushed; HTML-declared subscriptions where it is publisher or subscriber are never wired. The Plugster effectively exists in isolation — its own outlets work, but nothing else on the page can talk to it.
Omitting Plugster.unplug(instance) when removing a Plugster from the page
Subscriptions survive DOM removal. Handlers from the dead instance keep firing during subsequent interactions on the page, producing ghost behaviour. For Plugsters that live the whole page this is irrelevant; for anything mounted dynamically (route changes, factory output, modal lifecycles) it is mandatory.
Calling unplug(instance) without { destroy: true } when the class defines destroy()
Subscriptions detach cleanly but the resources destroy() would release (timers, observers, MobX disposers, open sockets) keep running. The framework deliberately never calls destroy() automatically — adding a destroy method should not silently change tear-down behavior for callers that do not know it exists. If you ship a Plugster with a destroy method, document that consumers should pass { destroy: true } when they unplug it.
Events #
Calling methods directly between Plugster instances
Writing headerPlugster.save() from another controller couples the two components and breaks the framework's communication model. All cross-Plugster communication must go through dispatchEvent / subscriptions — that is the only contract Plugster offers between instances. If two Plugsters need to coordinate, declare an event on the publisher, subscribe on the listener.
Dispatching events with ad-hoc payload shapes
this.dispatchEvent('save', { x: 1 }) where x was not part of the declared event signature. Subscribers receive payload fields they cannot rely on, and the contract drifts silently as the code evolves. Every payload field should appear in a registerEventSignature declaration on the publisher; if the payload needs to change, update the declaration first.
List outlets #
Using data-child-templates outlets for non-list content
The child-template API is for keyed, repeatable items. For single-slot panels — a detail pane, a contextual sidebar — use a regular outlet and assign content into it through the standard jQuery API. The list machinery (keys, indices, buildListItem) is overhead in that case.
Reading items via .getItems() when DOM order matters
getItems() returns a plain dictionary. JavaScript dictionary iteration is not guaranteed to match the order items appear in the DOM. If your code cares about list order (exporting items in order, computing neighbours, comparing against another sorted collection), use getItemsAsArray() instead — it returns items sorted by their tracked index, which matches the rendered DOM.
Localization #
Hardcoding display strings in JavaScript
Writing this._.saveButton.text('Save') instead of this._.saveButton.text(this.translateTo(this.lang, 'Save')). The page renders correctly in English but never adapts when the active language is anything else. Treat every literal string that ends up in the DOM at runtime as a localization candidate.
Framework boundary #
Using a component framework (React, Vue, Svelte, Angular) inside a Plugster
These frameworks own their own DOM lifecycle and conflict with Plugster's outlet binding and pub/sub model. The two cannot share rendering ownership of the same region cleanly. If you need a piece of UI that a component framework makes easy and Plugster makes hard, write that piece outside of any Plugster — the page can host both — but do not nest one inside the other.
Using global Flowbite auto-init attributes inside dynamically loaded Plugsters
Flowbite's global auto-init runs once on page load and does not re-run for dynamic content. If a Plugster mounted at runtime carries Flowbite attributes, those widgets will never initialize. Either initialize Flowbite components explicitly inside afterInit() or avoid the global auto-init pattern entirely.
State management #
Sharing mutable state between Plugster instances via globals
Storing shared data in window.* or exported module variables creates hidden coupling and breaks test isolation. Cross-instance state must flow through events. If two Plugsters genuinely need to coordinate on a piece of state, model that flow explicitly with declared events; the resulting coupling is at least visible.
Mutating outlets for state-driven content outside _render() (MobX pattern)
When using MobX autorun to project observable state into the DOM, update outlets inside _render() only. Updating outlets directly from event handlers in addition to the projection produces multiple mutation sites for the same DOM, and the rendered result drifts from the observed state. The Plugster + MobX integration is described in its own coco rule — most Plugsters do not need it.
Testing #
Skipping Plugster static state reset between tests
Plugster.registry, Plugster.explicitSubscriptions, Plugster.htmlDeclaredSubscriptions, Plugster.eventQueue, Plugster.childTemplateHtmlCache and Plugster.childTemplateRequestCache are all static. Without resetting them in beforeEach, handlers and cached template HTML leak across tests, and one test's wiring affects the next test's behavior. Reset all six explicitly.
beforeEach(() => {
Plugster.registry = undefined;
Plugster.explicitSubscriptions = undefined;
Plugster.htmlDeclaredSubscriptions = undefined;
Plugster.eventQueue = undefined;
Plugster.childTemplateHtmlCache = undefined;
Plugster.childTemplateRequestCache = undefined;
});