Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 23 additions & 1 deletion helpers/3p/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,29 @@ helpers.first = function(array, n) {

helpers.forEach = function(array) {
const options = arguments[arguments.length - 1];
var data = utils.createFrame(options, options.hash);
// Child `@` frame for the block body. Only `@root` is forwarded from the
// enclosing frame (options.data) into the block's own frame; no other private
// variables are copied across the helper boundary. createFrame(base, hash):
// 1. shallow-copies `base` ({ root }) into a new object,
// 2. sets `_parent` to `base`; overwritten below with the real enclosing
// frame, so explicit scope shifts (`@../index`) walk up to the parent,
// 3. copies hash args on top, so `{{#forEach arr foo=1}}` exposes `@foo`.
//
// Side effects / things NOT forwarded:
// - enclosing `@index`, `@key`, `@first`, `@last`, custom `@vars` are not
// visible directly (`@key` is empty), only via a scope shift (`@../key`).
// - a hash arg named `root` (e.g. `root="x"`) shadows `@root`.
// - this used to be `createFrame(options, ...)`, which copied the options
// object itself (`fn`, `inverse`, `hash`, `data`, ...) and no `@root`.
// Templates reaching `@root` via `@data.root` (or using `@fn`, `@hash`)
// get empty now; use `@root` directly.
// To make ALL enclosing `@` variables visible directly, use
// utils.createFrame(options.data || {}, options.hash)
// instead (it sets `_parent` itself), accepting that outer loop variables then
// leak into the block.
// The frame object is reused across iterations and mutated (`data.index`).
var data = utils.createFrame({ root: options.data && options.data.root }, options.hash);
data._parent = options.data;
if (!array) {
return '';
}
Expand Down
30 changes: 28 additions & 2 deletions helpers/3p/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,29 @@ helpers.forIn = function(obj) {
return obj.inverse(this);
}

var data = utils.createFrame(options, options.hash);
// Child `@` frame for the block body. Only `@root` is forwarded from the
// enclosing frame (options.data) into the block's own frame; no other private
// variables are copied across the helper boundary. createFrame(base, hash):
// 1. shallow-copies `base` ({ root }) into a new object,
// 2. sets `_parent` to `base`; overwritten below with the real enclosing
// frame, so explicit scope shifts (`@../index`) walk up to the parent,
// 3. copies hash args on top, so `{{#forIn obj foo=1}}` exposes `@foo`.
//
// Side effects / things NOT forwarded:
// - enclosing `@index`, `@key`, `@first`, `@last`, custom `@vars` are not
// visible directly (`@index` is empty), only via a scope shift (`@../index`).
// - a hash arg named `root` (e.g. `root="x"`) shadows `@root`.
// - this used to be `createFrame(options, ...)`, which copied the options
// object itself (`fn`, `inverse`, `hash`, `data`, ...) and no `@root`.
// Templates reaching `@root` via `@data.root` (or using `@fn`, `@hash`)
// get empty now; use `@root` directly.
// To make ALL enclosing `@` variables visible directly, use
// utils.createFrame(options.data || {}, options.hash)
// instead (it sets `_parent` itself), accepting that outer loop variables then
// leak into the block.
// The frame object is reused across iterations and mutated (`data.key`).
var data = utils.createFrame({ root: options.data && options.data.root }, options.hash);
data._parent = options.data;
var result = '';

for (var key in obj) {
Expand All @@ -90,7 +112,11 @@ helpers.forOwn = function(obj) {
return obj.inverse(this);
}

var data = utils.createFrame(options, options.hash);
// Child `@` frame forwarding only `@root`, with `_parent` linked to the
// enclosing frame for `@../` scope shifts. Same behaviour and side effects as
// `forIn` above.
var data = utils.createFrame({ root: options.data && options.data.root }, options.hash);
data._parent = options.data;
var result = '';

for (var key in obj) {
Expand Down
28 changes: 27 additions & 1 deletion helpers/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,33 @@ const factory = globals => {

/* Look for partial by name. */
const partial = globals.handlebars.partials[name] || options.fn;
return partial(this, { data: options.hash });
/*
* Child `@` frame for the partial / fallback body. Only `@root` is forwarded
* from the enclosing frame (options.data) into the block's own frame; no other
* private variables are copied across the helper boundary.
* handlebars.createFrame(base):
* 1. shallow-copies `base` ({ root }) into a new object,
* 2. sets `_parent` to `base`; overwritten below with the real enclosing
* frame, so explicit scope shifts (`@../index`) walk up to the parent.
* For a `{{#partial}}` body that parent is the frame at the
* `{{#block}}` call site (e.g. the layout), not where it was defined.
* Hash args are then copied on top, so `{{#block "x" foo=1}}` exposes `@foo`.
*
* Side effects / things NOT forwarded:
* - enclosing `@index`, `@key`, `@first`, `@last`, custom `@vars` are not
* visible directly, e.g. a block inside `{{#each}}` sees an empty
* `@index`, but `@../index` returns the loop's index.
* - a hash arg named `root` (e.g. `root="x"`) shadows `@root`.
* - this used to be `{ data: options.hash }`, i.e. the frame was ONLY the
* hash args, with no `@root`. The hash still becomes `@` variables.
* To make ALL enclosing `@` variables visible directly, use
* globals.handlebars.createFrame(options.data || {})
* instead (it sets `_parent` itself), accepting that outer loop variables
* then leak into the block.
*/
const data = globals.handlebars.createFrame({ root: options.data && options.data.root });
data._parent = options.data;
return partial(this, { data: Object.assign(data, options.hash) });
};
};

Expand Down
16 changes: 16 additions & 0 deletions helpers/dynamicComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ const factory = globals => {
path = Path.join(path, this['partial']);

if (globals.handlebars.partials[path] && Object.hasOwnProperty.call(globals.handlebars.partials, path)) {
/*
* KNOWN VARIATION: `@root` inside a dynamic component is NOT the page.
*
* The partial is called directly with no `data`, so Handlebars treats the
* call as a fresh top-level render and sets `@root` to `this` (the current
* item, e.g. the loop entry). Every other path gives `@root` = the page
* context, as asserted for every block helper in
* spec/helpers/blockHelpersRoot.js, and as the native `{{> partial}}`
* syntax does. Inside `{{#each items}}`, a component rendering
* `[{{@root.title}}|{{@index}}|{{title}}]` gives (v3 and v4 alike):
* {{> components/card}} -> [PAGE|0|ITEM0]
* {{{dynamicComponent "components"}}} -> [ITEM0||ITEM0]
* Also as a result: no enclosing `@` variables (`@index`, `@key`, ...)
* and no `@../` scope shifts are available inside the component.
*
*/
return globals.handlebars.partials[path](this);
}
};
Expand Down
87 changes: 86 additions & 1 deletion spec/helpers/3p/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,30 @@ describe('array', function() {
done();
});

it('should expose `@root`', function(done) {
var arr = [{name: 'a'}, {name: 'b'}];

var fn = hbs.compile('{{#forEach arr}}{{@root.title}}{{/forEach}}');
expect(fn({arr: arr, title: 'x'})).to.equal('xx');
done();
});

it('should expose `@index` and hash private variables alongside `@root`', function(done) {
var arr = [{name: 'a'}, {name: 'b'}];

var fn = hbs.compile('{{#forEach arr abc="y"}}{{@index}}{{@abc}}{{@root.title}}{{/forEach}}');
expect(fn({arr: arr, title: 'x'})).to.equal('0yx1yx');
done();
});

it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts', function(done) {
var arr = [{name: 'a'}];

var fn = hbs.compile('{{#each outer}}{{#forEach ../arr}}<{{@key}}{{@../key}}>{{/forEach}}{{/each}}');
expect(fn({arr: arr, outer: {k1: 1, k2: 2}})).to.equal('<k1><k2>');
done();
});

it('should return empty block', function(done) {
let arr = [];
var fn = hbs.compile('{{#forEach arr}}{{isLast}}{{/forEach}}');
Expand Down Expand Up @@ -563,4 +587,65 @@ describe('array', function() {
done();
});
});
});
});

describe('forEach @ variable boundary', function() {
// pierces: reaches the block body across the helper boundary.
// blocked: must NOT reach the block body directly.
// gotcha: documented behaviour that may surprise.
// l{n}: loop marker, one per iteration, each iteration ends with a single space;
// nested loops list each level outer-first (l0x1:).
const { renderString } = require('../../spec-helpers');
const boundaryContext = {
title: 'T',
nested: { title: 'N' },
arr: [{n: 'a'}, {n: 'b'}],
outer: [1, 2],
outerObj: {k1: 1, k2: 2},
obj: {a: 1, b: 2},
};
const boundaryCases = [
{ desc: 'pierces: @root from the top level',
input: '{{#forEach arr}}l{{@index}}:{{@root.title}} {{/forEach}}', output: 'l0:T l1:T ' },
{ desc: 'pierces: @root is the page, not the current context, when nested',
input: '{{#with nested}}{{#forEach ../arr}}l{{@index}}:{{@root.title}} {{/forEach}}{{/with}}', output: 'l0:T l1:T ' },
{ desc: 'pierces: own @index and hash private variables',
input: '{{#forEach arr foo="y"}}l{{@index}}:{{@index}}{{@foo}} {{/forEach}}', output: 'l0:0y l1:1y ' },
{ desc: 'pierces: @../index scope shift to an enclosing #each',
input: '{{#each outer}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:{{@../index}} {{/forEach}}{{/each}}', output: 'l0x0:0 l0x1:0 l1x0:1 l1x1:1 ' },
{ desc: 'pierces: @../key scope shift to an enclosing #each over an object',
input: '{{#each outerObj}}{{#forEach ../arr}}l{{@../key}}x{{@index}}:{{@../key}} {{/forEach}}{{/each}}', output: 'lk1x0:k1 lk1x1:k1 lk2x0:k2 lk2x1:k2 ' },
{ desc: 'pierces: @../first / @../last scope shift',
input: '{{#each outer}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:[{{@../first}}{{@../last}}] {{/forEach}}{{/each}}',
output: 'l0x0:[truefalse] l0x1:[truefalse] l1x0:[falsetrue] l1x1:[falsetrue] ' },
{ desc: 'pierces: @../../index two-level scope shift',
input: '{{#each outer}}{{#each @root.outerObj}}{{#forEach @root.arr}}l{{@../../index}}x{{@../key}}x{{@index}}:{{@../../index}}{{@../key}} {{/forEach}}{{/each}}{{/each}}',
output: 'l0xk1x0:0k1 l0xk1x1:0k1 l0xk2x0:0k2 l0xk2x1:0k2 l1xk1x0:1k1 l1xk1x1:1k1 l1xk2x0:1k2 l1xk2x1:1k2 ' },
{ desc: 'pierces: nested forEach sees its own @index and the outer one via @../index',
input: '{{#forEach arr}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:{{@index}}{{@../index}} {{/forEach}}{{/forEach}}', output: 'l0x0:00 l0x1:10 l1x0:01 l1x1:11 ' },
{ desc: 'blocked: enclosing @key',
input: '{{#each outerObj}}{{#forEach ../arr}}l{{@../key}}x{{@index}}:[{{@key}}] {{/forEach}}{{/each}}', output: 'lk1x0:[] lk1x1:[] lk2x0:[] lk2x1:[] ' },
{ desc: 'blocked: enclosing @first / @last',
input: '{{#each outer}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:[{{@first}}{{@last}}] {{/forEach}}{{/each}}', output: 'l0x0:[] l0x1:[] l1x0:[] l1x1:[] ' },
{ desc: 'blocked: enclosing custom @var (reachable via @../ only)',
input: '{{#forIn obj abc="x"}}{{#forEach @root.arr}}l{{@../key}}x{{@index}}:[{{@abc}}|{{@../abc}}] {{/forEach}}{{/forIn}}',
output: 'lax0:[|x] lax1:[|x] lbx0:[|x] lbx1:[|x] ' },
{ desc: 'blocked: options internals that used to leak (@data, @fn, @hash)',
input: '{{#forEach arr foo="y"}}l{{@index}}:[{{@data.root.title}}{{@fn}}{{@hash.foo}}] {{/forEach}}', output: 'l0:[] l1:[] ' },
{ desc: 'blocked: inner frame changes do not leak back out to the enclosing #each',
input: '{{#each outer}}l{{@index}}:{{#forEach ../arr}}{{/forEach}}{{@index}} {{/each}}', output: 'l0:0 l1:1 ' },
{ desc: 'gotcha: a hash arg named root shadows @root',
input: '{{#forEach arr root="X"}}l{{@index}}:{{@root}} {{/forEach}}', output: 'l0:X l1:X ' },
];

['v3', 'v4'].forEach(hbVersion => {
boundaryCases.forEach(({ desc, input, output }) => {
it(`${hbVersion} ${desc}`, function(done) {
renderString(input, boundaryContext, {}, {}, undefined, hbVersion).then(result => {
expect(result).to.equal(output);
done();
}).catch(done);
});
});
});
});
78 changes: 77 additions & 1 deletion spec/helpers/3p/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ describe('object', function() {
expect(fn(context)).to.equal(' b ');
done();
});

it('should expose `@root`:', function(done) {
var fn = hbs.compile('{{#forIn object}}{{@root.title}}{{/forIn}}');
expect(fn({object: {a: 1, b: 2}, title: 'x'})).to.equal('xx');
done();
});

it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts:', function(done) {
var fn = hbs.compile('{{#each outer}}{{#forIn ../object}}<{{@index}}{{@../index}}>{{/forIn}}{{/each}}');
expect(fn({object: {a: 1}, outer: [1, 2]})).to.equal('<0><1>');
done();
});
});

describe('forOwn', function() {
Expand Down Expand Up @@ -88,6 +100,18 @@ describe('object', function() {
expect(fn(context)).to.equal(' d ');
done();
});

it('should expose `@root`:', function(done) {
var fn = hbs.compile('{{#forOwn object}}{{@root.title}}{{/forOwn}}');
expect(fn({object: {a: 1, b: 2}, title: 'x'})).to.equal('xx');
done();
});

it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts:', function(done) {
var fn = hbs.compile('{{#each outer}}{{#forOwn ../object}}<{{@index}}{{@../index}}>{{/forOwn}}{{/each}}');
expect(fn({object: {a: 1}, outer: [1, 2]})).to.equal('<0><1>');
done();
});
});

describe('toPath', function() {
Expand Down Expand Up @@ -174,4 +198,56 @@ describe('object', function() {
done();
});
});
});
});

['forIn', 'forOwn'].forEach(helper => {
describe(`${helper} @ variable boundary`, function() {
// pierces: reaches the block body across the helper boundary.
// blocked: must NOT reach the block body directly.
// gotcha: documented behaviour that may surprise.
// l{n}: loop marker, one per iteration, each iteration ends with a single space;
// nested loops list each level outer-first (l0x1:).
const { renderString } = require('../../spec-helpers');
const boundaryContext = {
title: 'T',
nested: { title: 'N' },
outer: [1, 2],
outerObj: {k1: 1, k2: 2},
obj: {a: 1, b: 2},
};
const t = body => body.replace(/HELPER/g, helper);
const boundaryCases = [
{ desc: 'pierces: @root from the top level',
input: t('{{#HELPER obj}}l{{@key}}:{{@root.title}} {{/HELPER}}'), output: 'la:T lb:T ' },
{ desc: 'pierces: @root is the page, not the current context, when nested',
input: t('{{#with nested}}{{#HELPER ../obj}}l{{@key}}:{{@root.title}} {{/HELPER}}{{/with}}'), output: 'la:T lb:T ' },
{ desc: 'pierces: own @key and hash private variables',
input: t('{{#HELPER obj foo="y"}}l{{@key}}:{{@key}}{{@foo}} {{/HELPER}}'), output: 'la:ay lb:by ' },
{ desc: 'pierces: @../index scope shift to an enclosing #each',
input: t('{{#each outer}}{{#HELPER ../obj}}l{{@../index}}x{{@key}}:{{@../index}} {{/HELPER}}{{/each}}'), output: 'l0xa:0 l0xb:0 l1xa:1 l1xb:1 ' },
{ desc: 'pierces: @../key scope shift to an enclosing #each over an object',
input: t('{{#each outerObj}}{{#HELPER @root.obj}}l{{@../key}}x{{@key}}:{{@../key}} {{/HELPER}}{{/each}}'), output: 'lk1xa:k1 lk1xb:k1 lk2xa:k2 lk2xb:k2 ' },
{ desc: 'blocked: enclosing @index',
input: t('{{#each outer}}{{#HELPER ../obj}}l{{@../index}}x{{@key}}:[{{@index}}] {{/HELPER}}{{/each}}'), output: 'l0xa:[] l0xb:[] l1xa:[] l1xb:[] ' },
{ desc: 'blocked: enclosing @first / @last',
input: t('{{#each outer}}{{#HELPER ../obj}}l{{@../index}}x{{@key}}:[{{@first}}{{@last}}] {{/HELPER}}{{/each}}'), output: 'l0xa:[] l0xb:[] l1xa:[] l1xb:[] ' },
{ desc: 'blocked: options internals that used to leak (@data, @fn, @hash)',
input: t('{{#HELPER obj foo="y"}}l{{@key}}:[{{@data.root.title}}{{@fn}}{{@hash.foo}}] {{/HELPER}}'), output: 'la:[] lb:[] ' },
{ desc: 'blocked: inner frame changes do not leak back out to the enclosing #each',
input: t('{{#each outerObj}}l{{@key}}:{{#HELPER @root.obj}}{{/HELPER}}{{@key}} {{/each}}'), output: 'lk1:k1 lk2:k2 ' },
{ desc: 'gotcha: a hash arg named root shadows @root',
input: t('{{#HELPER obj root="X"}}l{{@key}}:{{@root}} {{/HELPER}}'), output: 'la:X lb:X ' },
];

['v3', 'v4'].forEach(hbVersion => {
boundaryCases.forEach(({ desc, input, output }) => {
it(`${hbVersion} ${desc}`, function(done) {
renderString(input, boundaryContext, {}, {}, undefined, hbVersion).then(result => {
expect(result).to.equal(output);
done();
}).catch(done);
});
});
});
});
});
Loading
Loading