Skip to content
This repository was archived by the owner on Apr 4, 2019. It is now read-only.

Commit fd6fd66

Browse files
committed
Merge pull request #318 from tildeio/idempotent-rerender
Idempotent rerender
2 parents 9b6ca3c + 85058de commit fd6fd66

27 files changed

+3314
-583
lines changed

LIFECYCLE.md

+294
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
An HTMLBars runtime environment implements a series of hooks (and
2+
keywords) that are responsible for guaranteeing the most important
3+
property of an HTMLBars template: idempotence.
4+
5+
This means that a template that is re-rendered with the same dynamic
6+
environment will result in the same DOM nodes (with the same identity)
7+
as the first render.
8+
9+
HTMLBars comes with support for idempotent helpers. This means that a
10+
helper implemented using the HTMLBars API is guaranteed to fulfill the
11+
idempotence requirement. That is because an HTMLBars template is a "pure
12+
function"; it takes in data parameters and returns data values.
13+
14+
> Block helpers also have access to `this.yield()`, which allows them to
15+
> render the block passed to the block helper, but they do not have
16+
> access to the block itself, nor the ability to directly insert the
17+
> block into the DOM. As long as `this.yield()` is invoked in two
18+
> successive renders, HTMLBars guarantees that the second call
19+
> effectively becomes a no-op and does not tear down the template.
20+
21+
HTMLBars environments are expected to implement an idempotent component
22+
implementation. What this means is that they are responsible for
23+
exposing a public API that ensures that users can write components with
24+
stable elements even when their attributes change. Ember.js has an
25+
implementation, but it's fairly involved.
26+
27+
## Hooks
28+
29+
An HTMLBars environment exposes a series of hooks that a runtime
30+
environment can use to define the behavior of templates. These hooks
31+
are defined on the `env` passed into an HTMLBars `render` function,
32+
and are invoked by HTMLBars as the template's dynamic portions are
33+
reached.
34+
35+
### The Scope Hooks
36+
37+
Scope management:
38+
39+
* `createFreshScope`: create a new, top-level scope. The default
40+
implementation of this hook creates a new scope with a `self` slot
41+
for the dynamic context and `locals`, a dictionary of local
42+
variables.
43+
* `createShadowScope`: create a new scope for a template that is
44+
being rendered in the middle of the render tree with a new,
45+
top-level scope (a "shadow root").
46+
* `createChildScope`: create a new scope that inherits from the parent
47+
scope. The child scope must reflect updates to `self` or `locals` on
48+
the parent scope automatically, so the default implementation of this
49+
hook uses `Object.create` on both the scope object and the locals.
50+
* `bindSelf`: a fresh `self` value has been provided for the scope
51+
* `bindLocal`: a specific local variable has been provided for
52+
the scope (through block arguments).
53+
54+
Scope lookup:
55+
56+
* `getRoot`: get the reference for the first identifier in a path. By
57+
default, this first looks in `locals`, and then looks in `self`.
58+
* `getChild`: gets the reference for subsequent identifiers in a path.
59+
* `getValue`: get the JavaScript value from the reference provided
60+
by the final call to `getChild`. Ember.js uses this series of
61+
hooks to create stable streams for each reference that remain
62+
stable across renders.
63+
64+
> All hooks other than `getValue` operate in terms of "references",
65+
> which are internal values that can be evaluated in order to get a
66+
> value that is suitable for use in user hooks. The default
67+
> implementation simply uses JavaScript values, making the
68+
> "references" simple pass-throughs. Ember.js uses stable "stream"
69+
> objects for references, and evaluates them on an as-needed basis.
70+
71+
### The Helper Hooks
72+
73+
* `hasHelper`: does a helper exist for this name?
74+
* `lookupHelper`: provide a helper function for a given name
75+
76+
### The Expression Hooks
77+
78+
* `concat`: takes an array of references and returns a reference
79+
representing the result of concatenating them.
80+
* `subexpr`: takes a helper name, a list of positional parameters
81+
and a hash of named parameters (as references), and returns a
82+
reference that, when evaluated, produces the result of invoking the
83+
helper with those *evaluated* positional and named parameters.
84+
85+
User helpers simply take positional and named parameters and return the
86+
result of doing some computation. They are intended to be "pure"
87+
functions, and are not provided with any other environment information,
88+
nor the DOM being built. As a result, they satisfy the idempotence
89+
requirement.
90+
91+
Simple example:
92+
93+
```hbs
94+
<p>{{upcase (format-person person)}}</p>
95+
```
96+
97+
```js
98+
helpers.upcase = function(params) {
99+
return params[0].toUpperCase();
100+
};
101+
102+
helpers['format-person'] = function(params) {
103+
return person.salutation + '. ' + person.first + ' ' + person.last;
104+
};
105+
```
106+
107+
The first time this template is rendered, the `subexpr` hook is invoked
108+
once for the `format-person` helper, and its result is provided to the
109+
`upcase` helper. The result of the `upcase` helper is then inserted into
110+
the DOM.
111+
112+
The second time the template is rendered, the same hooks are called.
113+
HTMLBars compares the result value with the last value inserted into the
114+
DOM, and if they are the same, does nothing.
115+
116+
Because HTMLBars is responsible for updating the DOM, and simply
117+
delegates to "pure helpers" to calculate the values to insert, it can
118+
guarantee idempotence.
119+
120+
## Keywords
121+
122+
HTMLBars allows a host environment to define *keywords*, which receive
123+
the full set of environment information (such as the current scope and a
124+
reference to the runtime) as well as all parameters as unevaluated
125+
references.
126+
127+
Keywords can be used to implement low-level behaviors that control the
128+
DOM being built, but with great power comes with great responsibility.
129+
Since a keyword has the ability to influence the ambient environment and
130+
the DOM, it must maintain the idempotence invariant.
131+
132+
To repeat, the idempotence requirement says that if a given template is
133+
executed multiple times with the same dynamic environment, it produces
134+
the same DOM. This means the exact same DOM nodes, with the same
135+
internal state.
136+
137+
This is also true for all child templates. Consider this template:
138+
139+
```hbs
140+
<h1>{{title}}</h1>
141+
142+
{{#if subtitle}}
143+
<h2>{{subtitle}}</h2>
144+
{{/if}}
145+
146+
<div>{{{body}}}</div>
147+
```
148+
149+
If this template is rendered first with a `self` that has a title,
150+
subtitle and body, and then rendered again with the same title and body
151+
but no subtitle, the second render will produce the same `<h1>` and same
152+
`<div>`, even though a part of the environment changes.
153+
154+
The general goal is that for a given keyword, if all of the inputs to
155+
the keyword have stayed the same, the produced DOM will stay the same.
156+
157+
## Lifecycle Example
158+
159+
To implement an idempotent keyword, you need to understand the basic
160+
lifecycle of a render node.
161+
162+
Consider this template:
163+
164+
```js
165+
{{#if subtitle}}
166+
<h2>{{subtitle}}</h2>
167+
{{/if}}
168+
```
169+
170+
The first time this template is rendered, the `{{#if}}` block receives a
171+
fresh, empty render node.
172+
173+
It evaluates `subtitle`, and if the value is truthy, yields to the
174+
block. HTMLBars creates the static parts of the template (the `<h2>`)
175+
and inserts them into the DOM).
176+
177+
When it descends into the block, it creates a fresh, empty render node
178+
and evaluates `subtitle`. It then sets the value of the render node to
179+
the evaluated value.
180+
181+
The second time the template is rendered, the `{{#if}}` block receives
182+
the same render node again.
183+
184+
It evaluates `subtitle`, and if the value is truthy, yields to the
185+
block. HTMLBars sees that the same block as last time was yielded, and
186+
**does not** replace the static portions of the block.
187+
188+
(If the value is falsy, it does not yield to the block. HTMLBars sees
189+
that the block was not yielded to, and prunes the DOM produced last
190+
time, and does not descend.)
191+
192+
It descends into the previous block, and repeats the process. It fetches
193+
the previous render node, instead of creating a fresh one, and evaluates
194+
`subtitle`.
195+
196+
If the value of `subtitle` is the same as the last value of `subtitle`,
197+
nothing happens. If the value of `subtitle` has changed, the render node
198+
is updated with the new value.
199+
200+
This example shows how HTMLBars itself guarantees idempotence. The
201+
easiest way for a keyword to satisfy these requirements are to implement
202+
a series of functions, as the next section will describe.
203+
204+
## Lifecycle More Precisely
205+
206+
```js
207+
export default {
208+
willRender: function(node, env) {
209+
// This function is always invoked before any other hooks,
210+
// giving the keyword an opportunity to coordinate with
211+
// the external environment regardless of whether this is
212+
// the first or subsequent render, and regardless of
213+
// stability.
214+
},
215+
216+
setupState: function(state, env, scope, params, hash) {
217+
// This function is invoked before `isStable` so that it can update any
218+
// internal state based on external changes.
219+
},
220+
221+
isEmpty: function(state, env, scope, params, hash) {
222+
// If `isStable` returns false, or this is the first render,
223+
// this function can return true to indicate that the morph
224+
// should be empty (and `render` should not be called).
225+
}
226+
227+
isPaused: function(state, env, scope, params, hash) {
228+
// This function is invoked on renders after the first render; if
229+
// it returns true, the entire subtree is assumed valid, and dirty
230+
// checking does not continue. This is useful during animations,
231+
// and in some cases, as a performance optimization.
232+
},
233+
234+
isStable: function(state, env, scope, params, hash) {
235+
// This function is invoked after the first render; it checks to see
236+
// whether the node is "stable". If the node is unstable, its
237+
// existing content will be removed and the `render` function is
238+
// called again to produce new values.
239+
},
240+
241+
rerender: function(morph, env, scope, params, hash, template, inverse
242+
visitor) {
243+
// This function is invoked if the `isStable` check returns true.
244+
// Occasionally, you may have a bit of work to do when a node is
245+
// stable even though you aren't tearing it down.
246+
},
247+
248+
render: function(node, env, scope, params, hash, template, inverse, visitor) {
249+
// This function is invoked on the first render, and any time the
250+
// isStable function returns false.
251+
}
252+
}
253+
```
254+
255+
For any given render, a keyword can end up in one of these states:
256+
257+
* **initial**: this is the first render for a given render node
258+
* **stable**: the DOM subtree represented by the render node do not
259+
need to change; continue revalidating child nodes
260+
* **unstable**: the DOM subtree represented by the render node is no
261+
longer valid; do a new initial render and replace the subtree
262+
* **prune**: remove the DOM subtree represented by the render node
263+
* **paused**: do not make any changes to this node or the DOM subtree
264+
265+
It is the keyword's responsibility to ensure that a node whose direct
266+
inputs have not changed remains **stable**. This does not mean that no
267+
descendant node will not be replaced, but only the precise nodes that
268+
have changed will be updated.
269+
270+
Note that these details should generally **not** be exposed to the user
271+
code that interacts with the keyword. Instead, the user code should
272+
generally take in inputs and produce outputs, and the keyword should use
273+
those outputs to determine whether the associated render node is stable
274+
or not.
275+
276+
Ember `{{outlet}}`s are a good example of this. The internal
277+
implementation of `{{outlet}}` is careful to avoid replacing any nodes
278+
if the current route has not changed, but the user thinks in terms of
279+
transitioning to a new route and rendering anew.
280+
281+
If the transition was to the same page (with a different model, say),
282+
the `{{outlet}}` keyword will make sure to consider the render node
283+
stable.
284+
285+
From the user's perspective, the transition always results in a complete
286+
re-render, but the keyword is responsible for maintaining the
287+
idempotence invariant when appropriate.
288+
289+
This also means that it's possible to precisely describe what
290+
idempotence guarantees exist. HTMLBars defines the guarantees for
291+
built-in constructs (including invoked user helpers), and each keyword
292+
defines the guarantees for the keyword. Since those are the only
293+
constructs that can directly manipulate the lexical environment or the
294+
DOM, that's all you need to know!

demos/compile-and-run.html

+12-4
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
var compiler = requireModule('htmlbars-compiler'),
3434
DOMHelper = requireModule('dom-helper').default,
3535
hooks = requireModule('htmlbars-runtime').hooks,
36-
helpers = requireModule('htmlbars-runtime').helpers;
36+
helpers = requireModule('htmlbars-runtime').helpers,
37+
render = requireModule('htmlbars-runtime').render;
3738

3839
var templateSource = localStorage.getItem('templateSource');
3940
var data = localStorage.getItem('templateData');
41+
var shouldRender = localStorage.getItem('shouldRender');
4042

4143
if (templateSource) {
4244
textarea.value = templateSource;
@@ -46,13 +48,19 @@
4648
dataarea.value = data;
4749
}
4850

51+
if (shouldRender === "false") {
52+
skipRender.checked = true;
53+
}
54+
4955
button.addEventListener('click', function() {
5056
var source = textarea.value,
5157
data = dataarea.value,
58+
shouldRender = !skipRender.checked,
5259
compileOptions;
5360

5461
localStorage.setItem('templateSource', source);
5562
localStorage.setItem('templateData', data);
63+
localStorage.setItem('shouldRender', shouldRender);
5664

5765
try {
5866
data = JSON.parse(data);
@@ -70,10 +78,10 @@
7078
var templateSpec = compiler.compileSpec(source, compileOptions);
7179
output.innerHTML = '<pre><code>' + templateSpec + '</code></pre>';
7280

73-
if (!skipRender.checked) {
81+
if (shouldRender) {
7482
var env = { dom: new DOMHelper(), hooks: hooks, helpers: helpers };
75-
var template = compiler.compile(source, compileOptions);
76-
var dom = template.render(data, env, output);
83+
var template = compiler.template(templateSpec);
84+
var dom = render(template, data, env, { contextualElement: output }).fragment;
7785

7886
output.innerHTML += '<hr><pre><code>' + JSON.stringify(data) + '</code></pre><hr>';
7987
output.appendChild(dom);

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"ember-cli-sauce": "^1.0.0",
4141
"git-repo-version": "^0.1.2",
4242
"handlebars": "mmun/handlebars.js#new-ast-3238645f",
43-
"morph-range": "^0.1.2",
43+
"morph-range": "^0.2.1",
4444
"qunit": "^0.7.2",
4545
"rsvp": "~3.0.6"
4646
}

0 commit comments

Comments
 (0)