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 diff
ing), 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 Widget
s.
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
Post
Ember object itself. This is done for performance reasons. In Ember, callingget
on a property has overhead that, while small, adds up when you are rendering as fast as possible. In our old code we would be callingget
repeatedly, frequently on the same property rather than caching it locally. In the new code, we transform aPost
ember model into a plain javascript object that we don’t have to callget
on 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 Widget
s 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!