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

Idempotent rerender #318

Merged
merged 93 commits into from
Mar 30, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
6777502
Add ability for templates to reuse morphs
Feb 3, 2015
856984e
Encapsulate state needed for re-render
Feb 3, 2015
b98ea5a
Extract static cached fragment logic to runtime
tomdale Feb 4, 2015
389becc
Break apart hydration code into phases
tomdale Feb 4, 2015
4524d58
Introduce element morphs
tomdale Feb 5, 2015
c7a4af7
Fix failing tests
tomdale Feb 5, 2015
f491efd
renderNodes -> buildRenderNodes
Feb 5, 2015
9221a17
Guard if no parent and no contextualElement
Feb 5, 2015
6de350f
Expose public API for easy rerendering
Feb 5, 2015
95d795d
Block helpers mutate in place rather than return
Feb 6, 2015
fa3db79
Rename rerender to revalidate
Feb 6, 2015
5841c13
Simplify template rendering in block helpers
Feb 6, 2015
58e9af6
Propagate ownerNode through the render nodes
Feb 6, 2015
3845ac9
Maintain consistent “shape” for options hash
Feb 6, 2015
3867808
`get` helper should get render node for dirtying
Feb 7, 2015
766d807
Introduce dirtiness to render node invalidation
Feb 7, 2015
c3315f6
Defer more of rendering to the runtime
wycats Feb 8, 2015
df06837
Remove unnecessary inlined render function
wycats Feb 8, 2015
102bc6a
Use extracted morph-range library
Feb 9, 2015
7a86d88
Ensure templates always have stable boundaries
Feb 10, 2015
2d966c3
Clean up scope handling
wycats Feb 10, 2015
54a4436
Make ordering of scope/env consistent
wycats Feb 11, 2015
1469fbf
Tighten up argument order
Feb 12, 2015
906bc5e
Provide `this.yield` sugar in block helpers
Feb 12, 2015
fbcdf48
Hooks don't need to understand contextualElement
Feb 13, 2015
67f10c0
Start writing host hook documentation
Feb 13, 2015
d27c910
Implement dirtying render with layouts
wycats Feb 11, 2015
0f69968
Factor the hooks better
wycats Feb 11, 2015
b6779d1
Move dirty checking out of the hooks
wycats Feb 11, 2015
d1f0773
Don't defer yielding template anymore
wycats Feb 11, 2015
7b85d7c
Implement `yieldItem` for #each helper
Feb 17, 2015
eea6af7
Try to make more of the hot path static
wycats Feb 12, 2015
9ac6894
Missing file
wycats Feb 13, 2015
21e0031
Make sure to walk through MorphLists
Feb 17, 2015
09471b7
Provide a hook to normalize values
Feb 17, 2015
8d4f548
Compatibility with more downstream builds
Feb 17, 2015
c1730ab
Improve Ember integration
Feb 18, 2015
90fe5c1
linkRenderNode can cache last linked values
Feb 18, 2015
e1bd02c
Cleanup pruned render nodes
Feb 18, 2015
acbab7a
Inline helpers can insert new top-level templates
Feb 19, 2015
739e18b
Make it possible to destroy a top-level morph
Feb 19, 2015
e133d60
Always revalidate child nodes for all statements
Feb 21, 2015
8c9634e
Properly propagate visitor through hooks
Feb 21, 2015
838ccdd
Avoid revalidating children twice
Feb 21, 2015
99d5bb3
Fix some bugs related to template passthrough
Feb 21, 2015
ed5ccab
Expose revalidation logic
Feb 21, 2015
156dac2
Make sure revalidation is properly recursive
Feb 21, 2015
1f71c6a
Allow hosts to create fresh scope directly
Feb 23, 2015
2d22fbf
Add a document explaining HTMLBars lifecycle
wycats Feb 20, 2015
fb303e6
Clarify language
wycats Feb 20, 2015
de5753e
Cleaner abstraction for common keyword patterns
Feb 23, 2015
9da2487
setupState & shouldPrune happen on initial render
Feb 23, 2015
24ebdf0
Pass in needed state to render keyword hook
Feb 23, 2015
ae859e6
Expand hooks for Ember integration
Feb 26, 2015
083d4af
Assorted cleanup
wycats Feb 20, 2015
27195e0
Make sure `content` nodes support redirection
Feb 26, 2015
381c7b3
`hostBlock` takes options for `createShadowScope`
Feb 26, 2015
bf387f5
Fix a bug in yieldItem
Feb 27, 2015
bef607c
Expose just the block handling part of `block`
Feb 27, 2015
7555ebf
Make RenderResult an object
wycats Feb 21, 2015
35cdb72
A few things to improve baseline performance
Feb 27, 2015
0562518
Add a description of a performance optimization
Feb 27, 2015
dca4418
Give host opportunity to handle scope extensions
Feb 27, 2015
d443a67
Rename internal pruneMorph method to clearMorph
Feb 27, 2015
d922530
Always invoke `bindScope()` hook
Feb 27, 2015
9c56290
Extract dirtiness check and add subtree dirtiness
wycats Feb 21, 2015
999a838
Pass env to `bindSelf` and `bindScope`
tilde-engineering Mar 1, 2015
60f33e0
Better abstractions for multi-level blocks
Mar 2, 2015
af6e7e7
Simplify nested blocks
wycats Feb 22, 2015
9fb2b27
Export blockFor as a public utility (Title 2)
Mar 2, 2015
4c10607
Pass more information to yielded blocks
Mar 2, 2015
51123cc
Make blockFor and manualElement more flexible
Mar 2, 2015
4079f76
Fix bug
Mar 3, 2015
d35ceb2
Upgrade to latest morph-range
tilde-engineering Mar 3, 2015
b24e3ed
Pass env to linkRenderNode
tilde-engineering Mar 9, 2015
2bb3fdf
Keyword cleanup
tilde-engineering Mar 11, 2015
550312c
Expose new morph creation methods
Mar 12, 2015
d41095b
Let yieldItem support self bindings
mmun Mar 12, 2015
e7e9893
Extract invokeHelper and keyword hooks
Mar 13, 2015
a8bfdd7
Implement a few more scope-related hooks
Mar 13, 2015
5d0d86d
Differentiate clearing and destroying
Mar 13, 2015
f73c1eb
Allow Morph types to be subclassed
wycats Mar 10, 2015
42b9bd5
Implement a built-in default stability check
wycats Mar 10, 2015
56d6a9b
Improve child env support
wycats Mar 10, 2015
4146d66
Export clearMorph for framework cleanup
Mar 18, 2015
ad6bf44
Improve cleanup
Mar 19, 2015
1b993fe
Element modifiers support keywords
mmun Mar 28, 2015
86b6d48
Tweak tests to be more stable across browsers
mmun Mar 29, 2015
065680b
Use array utils to support old IE
mmun Mar 29, 2015
d8223db
Use nodeValue instead of textContent to support old IE
mmun Mar 29, 2015
7b93313
Remove trailing commas
mmun Mar 29, 2015
8fe4bdd
Fix module import
mmun Mar 29, 2015
85058de
Use only HTML4 tags in tests
mmun Mar 30, 2015
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions LIFECYCLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
An HTMLBars runtime environment implements a series of hooks (and
keywords) that are responsible for guaranteeing the most important
property of an HTMLBars template: idempotence.

This means that a template that is re-rendered with the same dynamic
environment will result in the same DOM nodes (with the same identity)
as the first render.

HTMLBars comes with support for idempotent helpers. This means that a
helper implemented using the HTMLBars API is guaranteed to fulfill the
idempotence requirement. That is because an HTMLBars template is a "pure
function"; it takes in data parameters and returns data values.

> Block helpers also have access to `this.yield()`, which allows them to
> render the block passed to the block helper, but they do not have
> access to the block itself, nor the ability to directly insert the
> block into the DOM. As long as `this.yield()` is invoked in two
> successive renders, HTMLBars guarantees that the second call
> effectively becomes a no-op and does not tear down the template.

HTMLBars environments are expected to implement an idempotent component
implementation. What this means is that they are responsible for
exposing a public API that ensures that users can write components with
stable elements even when their attributes change. Ember.js has an
implementation, but it's fairly involved.

## Hooks

An HTMLBars environment exposes a series of hooks that a runtime
environment can use to define the behavior of templates. These hooks
are defined on the `env` passed into an HTMLBars `render` function,
and are invoked by HTMLBars as the template's dynamic portions are
reached.

### The Scope Hooks

Scope management:

* `createFreshScope`: create a new, top-level scope. The default
implementation of this hook creates a new scope with a `self` slot
for the dynamic context and `locals`, a dictionary of local
variables.
* `createShadowScope`: create a new scope for a template that is
being rendered in the middle of the render tree with a new,
top-level scope (a "shadow root").
* `createChildScope`: create a new scope that inherits from the parent
scope. The child scope must reflect updates to `self` or `locals` on
the parent scope automatically, so the default implementation of this
hook uses `Object.create` on both the scope object and the locals.
* `bindSelf`: a fresh `self` value has been provided for the scope
* `bindLocal`: a specific local variable has been provided for
the scope (through block arguments).

Scope lookup:

* `getRoot`: get the reference for the first identifier in a path. By
default, this first looks in `locals`, and then looks in `self`.
* `getChild`: gets the reference for subsequent identifiers in a path.
* `getValue`: get the JavaScript value from the reference provided
by the final call to `getChild`. Ember.js uses this series of
hooks to create stable streams for each reference that remain
stable across renders.

> All hooks other than `getValue` operate in terms of "references",
> which are internal values that can be evaluated in order to get a
> value that is suitable for use in user hooks. The default
> implementation simply uses JavaScript values, making the
> "references" simple pass-throughs. Ember.js uses stable "stream"
> objects for references, and evaluates them on an as-needed basis.

### The Helper Hooks

* `hasHelper`: does a helper exist for this name?
* `lookupHelper`: provide a helper function for a given name

### The Expression Hooks

* `concat`: takes an array of references and returns a reference
representing the result of concatenating them.
* `subexpr`: takes a helper name, a list of positional parameters
and a hash of named parameters (as references), and returns a
reference that, when evaluated, produces the result of invoking the
helper with those *evaluated* positional and named parameters.

User helpers simply take positional and named parameters and return the
result of doing some computation. They are intended to be "pure"
functions, and are not provided with any other environment information,
nor the DOM being built. As a result, they satisfy the idempotence
requirement.

Simple example:

```hbs
<p>{{upcase (format-person person)}}</p>
```

```js
helpers.upcase = function(params) {
return params[0].toUpperCase();
};

helpers['format-person'] = function(params) {
return person.salutation + '. ' + person.first + ' ' + person.last;
};
```

The first time this template is rendered, the `subexpr` hook is invoked
once for the `format-person` helper, and its result is provided to the
`upcase` helper. The result of the `upcase` helper is then inserted into
the DOM.

The second time the template is rendered, the same hooks are called.
HTMLBars compares the result value with the last value inserted into the
DOM, and if they are the same, does nothing.

Because HTMLBars is responsible for updating the DOM, and simply
delegates to "pure helpers" to calculate the values to insert, it can
guarantee idempotence.

## Keywords

HTMLBars allows a host environment to define *keywords*, which receive
the full set of environment information (such as the current scope and a
reference to the runtime) as well as all parameters as unevaluated
references.

Keywords can be used to implement low-level behaviors that control the
DOM being built, but with great power comes with great responsibility.
Since a keyword has the ability to influence the ambient environment and
the DOM, it must maintain the idempotence invariant.

To repeat, the idempotence requirement says that if a given template is
executed multiple times with the same dynamic environment, it produces
the same DOM. This means the exact same DOM nodes, with the same
internal state.

This is also true for all child templates. Consider this template:

```hbs
<h1>{{title}}</h1>

{{#if subtitle}}
<h2>{{subtitle}}</h2>
{{/if}}

<div>{{{body}}}</div>
```

If this template is rendered first with a `self` that has a title,
subtitle and body, and then rendered again with the same title and body
but no subtitle, the second render will produce the same `<h1>` and same
`<div>`, even though a part of the environment changes.

The general goal is that for a given keyword, if all of the inputs to
the keyword have stayed the same, the produced DOM will stay the same.

## Lifecycle Example

To implement an idempotent keyword, you need to understand the basic
lifecycle of a render node.

Consider this template:

```js
{{#if subtitle}}
<h2>{{subtitle}}</h2>
{{/if}}
```

The first time this template is rendered, the `{{#if}}` block receives a
fresh, empty render node.

It evaluates `subtitle`, and if the value is truthy, yields to the
block. HTMLBars creates the static parts of the template (the `<h2>`)
and inserts them into the DOM).

When it descends into the block, it creates a fresh, empty render node
and evaluates `subtitle`. It then sets the value of the render node to
the evaluated value.

The second time the template is rendered, the `{{#if}}` block receives
the same render node again.

It evaluates `subtitle`, and if the value is truthy, yields to the
block. HTMLBars sees that the same block as last time was yielded, and
**does not** replace the static portions of the block.

(If the value is falsy, it does not yield to the block. HTMLBars sees
that the block was not yielded to, and prunes the DOM produced last
time, and does not descend.)

It descends into the previous block, and repeats the process. It fetches
the previous render node, instead of creating a fresh one, and evaluates
`subtitle`.

If the value of `subtitle` is the same as the last value of `subtitle`,
nothing happens. If the value of `subtitle` has changed, the render node
is updated with the new value.

This example shows how HTMLBars itself guarantees idempotence. The
easiest way for a keyword to satisfy these requirements are to implement
a series of functions, as the next section will describe.

## Lifecycle More Precisely

```js
export default {
willRender: function(node, env) {
// This function is always invoked before any other hooks,
// giving the keyword an opportunity to coordinate with
// the external environment regardless of whether this is
// the first or subsequent render, and regardless of
// stability.
},

setupState: function(state, env, scope, params, hash) {
// This function is invoked before `isStable` so that it can update any
// internal state based on external changes.
},

isEmpty: function(state, env, scope, params, hash) {
// If `isStable` returns false, or this is the first render,
// this function can return true to indicate that the morph
// should be empty (and `render` should not be called).
}

isPaused: function(state, env, scope, params, hash) {
// This function is invoked on renders after the first render; if
// it returns true, the entire subtree is assumed valid, and dirty
// checking does not continue. This is useful during animations,
// and in some cases, as a performance optimization.
},

isStable: function(state, env, scope, params, hash) {
// This function is invoked after the first render; it checks to see
// whether the node is "stable". If the node is unstable, its
// existing content will be removed and the `render` function is
// called again to produce new values.
},

rerender: function(morph, env, scope, params, hash, template, inverse
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing trailing coma?

visitor) {
// This function is invoked if the `isStable` check returns true.
// Occasionally, you may have a bit of work to do when a node is
// stable even though you aren't tearing it down.
},

render: function(node, env, scope, params, hash, template, inverse, visitor) {
// This function is invoked on the first render, and any time the
// isStable function returns false.
}
}
```

For any given render, a keyword can end up in one of these states:

* **initial**: this is the first render for a given render node
* **stable**: the DOM subtree represented by the render node do not
need to change; continue revalidating child nodes
* **unstable**: the DOM subtree represented by the render node is no
longer valid; do a new initial render and replace the subtree
* **prune**: remove the DOM subtree represented by the render node
* **paused**: do not make any changes to this node or the DOM subtree

It is the keyword's responsibility to ensure that a node whose direct
inputs have not changed remains **stable**. This does not mean that no
descendant node will not be replaced, but only the precise nodes that
have changed will be updated.

Note that these details should generally **not** be exposed to the user
code that interacts with the keyword. Instead, the user code should
generally take in inputs and produce outputs, and the keyword should use
those outputs to determine whether the associated render node is stable
or not.

Ember `{{outlet}}`s are a good example of this. The internal
implementation of `{{outlet}}` is careful to avoid replacing any nodes
if the current route has not changed, but the user thinks in terms of
transitioning to a new route and rendering anew.

If the transition was to the same page (with a different model, say),
the `{{outlet}}` keyword will make sure to consider the render node
stable.

From the user's perspective, the transition always results in a complete
re-render, but the keyword is responsible for maintaining the
idempotence invariant when appropriate.

This also means that it's possible to precisely describe what
idempotence guarantees exist. HTMLBars defines the guarantees for
built-in constructs (including invoked user helpers), and each keyword
defines the guarantees for the keyword. Since those are the only
constructs that can directly manipulate the lexical environment or the
DOM, that's all you need to know!
16 changes: 12 additions & 4 deletions demos/compile-and-run.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
var compiler = requireModule('htmlbars-compiler'),
DOMHelper = requireModule('dom-helper').default,
hooks = requireModule('htmlbars-runtime').hooks,
helpers = requireModule('htmlbars-runtime').helpers;
helpers = requireModule('htmlbars-runtime').helpers,
render = requireModule('htmlbars-runtime').render;

var templateSource = localStorage.getItem('templateSource');
var data = localStorage.getItem('templateData');
var shouldRender = localStorage.getItem('shouldRender');

if (templateSource) {
textarea.value = templateSource;
Expand All @@ -46,13 +48,19 @@
dataarea.value = data;
}

if (shouldRender === "false") {
skipRender.checked = true;
}

button.addEventListener('click', function() {
var source = textarea.value,
data = dataarea.value,
shouldRender = !skipRender.checked,
compileOptions;

localStorage.setItem('templateSource', source);
localStorage.setItem('templateData', data);
localStorage.setItem('shouldRender', shouldRender);

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

if (!skipRender.checked) {
if (shouldRender) {
var env = { dom: new DOMHelper(), hooks: hooks, helpers: helpers };
var template = compiler.compile(source, compileOptions);
var dom = template.render(data, env, output);
var template = compiler.template(templateSpec);
var dom = render(template, data, env, { contextualElement: output }).fragment;

output.innerHTML += '<hr><pre><code>' + JSON.stringify(data) + '</code></pre><hr>';
output.appendChild(dom);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"ember-cli-sauce": "^1.0.0",
"git-repo-version": "^0.1.2",
"handlebars": "mmun/handlebars.js#new-ast-3238645f",
"morph-range": "^0.1.2",
"morph-range": "^0.2.1",
"qunit": "^0.7.2",
"rsvp": "~3.0.6"
}
Expand Down
Loading