This is out of date. See Widgets, the Widget API and their roadmap?
The latest builds of Discourse are much faster at rendering topics thanks to our re-written post stream. I’ve written up our new plugin API but so far haven’t explained how the code all fits together. The purpose of this topic is to allow Discourse developers to understand how the new code works.
What’s a Virtual DOM?
A Virtual DOM is a data structure that enables browsers to re-render dynamic content very quickly. The technique was pioneered by React but has since been integrated into many other frameworks.
The basic idea is that updating the HTML content of a browser is slow, so it’s much faster to update a data structure in memory, then figure out what changed (knowng as diffing), so you can instruct the browser to only update exactly what changed.
Discourse uses the virtual-dom library to perform all the hard work. However, that library is a bit too low level, so it’s been abstracted in Discourse by a series of classes called Widgets.
Anatomy of a Widget
A Widget is a class with a function called html() that produces the virtual dom necessary to render itself. Here’s an example of a simple Widget:
import { createWidget } from 'discourse/widgets/widget';
createWidget('my-widget', {
tagName: 'div.hello',
html() {
return "hello world";
}
});
The above code registers a widget called my-widget, which will be rendered in the browser as <div class='hello'>hello world</div>. It can be rendered in any of our Handlebars templates using the mount-widget helper:
{{mount-widget widget="my-widget"}}
If you wanted to return more HTML, you can include the h helper and use it to emit more formatting:
import { createWidget } from 'discourse/widgets/widget';
import { h } from 'virtual-dom';
createWidget('my-widget', {
tagName: 'div.hello',
html() {
return h('span.greeting', h('b', "hello world"));
}
});
The above would render:
<div class="hello"><span class="greeting"><b>hello world</b></span></div>
Rendering Attributes
So far our widget is 100% static, and in reality Discourse’s code is quite dynamic. When a widget is rendered, it is passed a series of attributes. In our post stream, for example, these attributes are a plain Javascript object with the attributes of a post.
Aside: you might wonder why we don’t render from our
PostEmber object itself. This is done for performance reasons. In Ember, callinggeton a property has overhead that, while small, adds up when you are rendering as fast as possible. In our old code we would be callinggetrepeatedly, frequently on the same property rather than caching it locally. In the new code, we transform aPostember model into a plain javascript object that we don’t have to callgeton to retrieve attributes.
(I should add that this is one of the uglier aspects of the new post stream code, and I’m not 100% sure we need to do it. It may change.)
You can pass attributes to be rendered by a widget using the args property. Let’s say you had a user object with {name: 'Robin'} in your template. You can pass it to be rendered in the widget like so:
{{mount-widget widget="display-name" args=user}}
Then your widget would be passed the user object as the first argument to html():
import { createWidget } from 'discourse/widgets/widget';
import { h } from 'virtual-dom';
createWidget('display-name', {
tagName: 'div.name',
html(attrs) {
return h('b', attrs.name);
}
});
The final HTML would be:
<div class="name"><b>Robin</b></div>
Widgets can also render other widgets. Use attach to render a widget in an html method:
html(attrs) {
return this.attach('another-widget', attrs);
}
It’s widgets all the way down!
State and Actions
Widgets can also contain state. You can think of state as data that you want to keep around the next time your widget is rendered. (For example, if you wanted to remember if a post is collapsed or expanded, it would make sense to store that state on the widget itself, and not the the post itself.)
To add state to your widget, create a defaultState method that returns an object that will represent its default state the first time the widget is rendered.
Each widget that has state also needs a unique key, so we have a function buildKey which returns a string. If you use the widget more than once you need to make this key unique, so you could for example base it on the id of an attribute that is passed in.
Your state is basically useless without a form of user interaction, so let’s also implement a click() function. click is called whenever the user clicks on your widget. Let’s look at it together:
import { createWidget } from 'discourse/widgets/widget';
createWidget('increment-button', {
tagName: 'button',
buildKey: () => 'increment-button',
defaultState() {
return { clicks: 0 };
},
html(attrs, state) {
return `Click me! ${state.clicks} clicks`;
},
click() {
this.state.clicks++;
}
});
The above code will render a button that, when clicked, will increment a counter.
Widgets can also send actions up. This means that you can call any pre-existing Discourse action. Simply pass it into the widget the same way you would for an Ember component:
{{mount-widget widget="my-widget" deleteThing="deleteThing"}}
Then in your click handler you can call sendWidgetAction to call it:
click() {
this.sendWidgetAction('deleteThing');
}
Bindings and Rerendering Content
If you’ve been working with Ember for a while, you might be wondering how the widgets know when they need to be rerendered since they aren’t using Ember’s bindings or observers system. In fact, this is one of the big trade offs widgets make: in Ember’s view rendering this is handled transparently for you, but with Widgets we assume the widget only needs to be re-rendered when you interact with them.
Widgets are rerendered when:
-
A user clicks on them
-
After any actions are triggered
-
If an action returns a promise, after that promise has resolved
The above cases count for the vast majority of the times when Discourse needs to rerender a widget. However, if you have an edge case, a widget can rerender itself by calling this.scheduleRerender().
Render calls are coalesced on the Ember Run Loop, so don’t be afraid to call this.scheduleRerender() multiple times in the same event loop – the widget will only render one time for all the changes you’ve queued.
Where to go from here
If all of the above makes sense, you are probably ready to jump into the codebase! In Discourse, all Widgets used to render the post stream are in the widgets folder.
There are also quite a few tests that are worth looking through to get an idea of how different parts of the application were implemented as well as how they can be tested.
Of course, I’m also around here on meta for questions, so feel free to ask if something doesn’t make sense. I probably missed something!


