This was an intriguing discovery and I really wanted to find the reason for such seemingly strange behavior. I did some digging into the Handlebars source code and I found the relevant code was to be found on line 176 of lib/handlebars/runtime.js.
Handlebars maintains a stack of context objects, the top object of which is the {{this}}
reference within the currently executing Handlebars template block. As nested blocks are encountered in the executing template a new object is pushed to the stack. This is what permits code like the following:
{{#each this.list}}
{{this}}
{{/each}}
On the first line, this
is an object with an enumerable property called "list". On line two, this
is the currently iterated item of the list
object. As the question points out, Handlebars allows us to use ../
to access context objects deeper in the stack. In the example above, if we want to access the list
object from within the #each
helper, we would have had to use {{../this.list}}
.
With this brief summary out of the way, the question remains: Why does our context stack appear to break when the value of the current iteration of the outer for
loop is equal to that of the inner for
loop?
The relevant code from the Handlebars source is the following:
let currentDepths = depths;
if (depths && context != depths[0]) {
currentDepths = [context].concat(depths);
}
depths
is the internal stack of context objects, context
is the object passed to the currently executing block, and currentDepths
is the context stack that is made available to the executing block. As you can see, the current context
is pushed to the available stack only if context
is not loosely equal to the current top of the stack, depths[0]
.
Let's apply this logic to the code in the question.
When the context of the outer #for
block is 15
and the context of the inner #for
block is 0
:
depths
is: [15, {ROOT_OBJECT}]
(Where {ROOT_OBJECT} means the
object that was the argument to the template method call.)
- Becuase
0 != 15
, currentDepths
becomes: [0, 15, {ROOT_OBJECT}]
.
- Within the inner
#for
block of the template, {{this}}
is 0
, {{../this}}
is 15
and {{../../this}}
is {ROOT_OBJECT}.
However, when the outer and inner #for
blocks each have a context value of 15
, we get the following:
depths
is: [15, {ROOT_OBJECT}]
.
- Because
15 == 15
, currentDepths = depths = [15, {ROOT_OBJECT}]
.
- Within the inner
#for
block of the template, {{this}}
is 15
, {{../this}}
is {ROOT_OBJECT}, and {{../../this}}
is undefined
.
This is why it appears that your {{../this}}
skips a level when the outer and inner #for
blocks have the same value. It is actually because the value of the inner #for
is not pushed to the context stack!
It is at this point that we should ask why Handlebars behaves this way and to determine whether this is a feature or a bug.
It so happens that this code was added intentionally to solve an issue that users were experiencing with Handlebars. The issue can be demonstrated by way of example:
Assuming a context object of:
{
config: {
showName: true
},
name: 'John Doe'
}
Users found the following use case to be counter-intuitive:
{{#with config}}
{{#if showName}}
{{../../name}}
{{/if}}
{{/with}}
The specific issue was with the necessity for the double ../
to access the root object: {{../../name}}
rather than {{../name}}
. Users felt that since the context object within {{#if showName}}
was the config
object, then stepping-up one level, ../
, should access the "parent" of config
- the root object. The reason that two steps were necessary was because Handlebars was creating a context stack object for each block helper. This means that two steps are required to get to the root context; the first step gets the context of {{#with config}}
, and the second step gets the context of the root.
A commit was made that prevents the pushing of a context object to the available context stack when the new context object is loosely equal to the object at the top of the context stack. The responsible code is the source code we looked at above. As of version 4.0.0 of Handlebars.js, our config example will fail. It now requires only a single ../
step.
Getting back to the code example in the original question, the reason that the 15
from the outer #for
block is determined as equal to the 15
in the inner #for
block is due to how number types are compared in JavaScript; two objects of the Number type are equal if they each have the same value. This is in contrast to the Object type, for which two objects are equal only if they reference the same object in memory. This means that if we re-wrote the original example code to use Object types instead of Number types for the contexts, then we would never meet the conditional comparison statement and we would always have the expected context stack within our inner #for
block.
The for loop in our helper would be updated to pass an Object type as the context of the frame it creates:
for(var i = from; i <= to; i += incr) {
accum += block.fn({ value: i });
}
And our template would now need to access the relevant property of this object:
{{log ../this.value}}
<span>{{../this.value}}:{{this.value}}</span><br>
With these edits, you should find your code performing as you expected.
It's somewhat subjective to declare whether or not this is a bug with Handlebars. The conditional was added intentionally and the resultant behavior does what it was intended to do. However, I find it hard to imagine a case in which this behavior would be expected or desirable when the contexts involved are primitives and not Object types. It might be reasonable for the Handlebars code to be made to do the comparison on Object types only. I think there is a legitimate case here to open an issue.