Repository navigation
Conversation
`{{@root}}` rendered empty inside several custom block helpers because
they replaced the Handlebars private-variable (`@`) frame with an object
that had no `root` key. Handlebars itself handles `@root` correctly; the
built-in helpers derive child frames from `options.data`.
Root causes
-----------
- helpers/block.js called `partial(this, { data: options.hash })`, so the
frame was *only* the hash args. Every `{{#partial}}` body rendered via
`{{#block}}`/`{{{block}}}`, and every block fallback, lost `@root` (and
every other `@` variable). Stencil themes wrap most page content this
way.
- helpers/3p/array.js (`forEach`) and helpers/3p/object.js (`forIn`,
`forOwn`) called `utils.createFrame(options, options.hash)`, copying the
options object (`fn`, `inverse`, `hash`, `data`, ...) instead of
`options.data`. `iterate` delegates to `forEach`/`forOwn`, so it was
broken too.
Fix: forward only @root, link _parent for scope shifts
-----------------------------------------------------
Each affected helper now builds its frame as:
data = createFrame({ root: options.data && options.data.root }, hash)
data._parent = options.data
- `@root` is forwarded; it is the page context everywhere.
- No other enclosing `@` variable (`@index`, `@key`, `@first`, `@last`,
custom hash vars) is visible directly inside the block.
- `_parent` points at the real enclosing frame, so explicit scope shifts
(`@../index`, `@../../index`, `@../key`) resolve to the parent. For a
`{{#partial}}` body, that parent is the `{{#block}}` call site.
- Hash args are still exposed as `@` variables (`{{#block "x" foo=1}}`
gives `@foo`).
Each call site carries a comment documenting the side effects:
- a hash arg named `root` (e.g. `root="x"`) shadows `@root`;
- `@data.root`, `@fn`, `@hash`, which leaked through by accident from
`createFrame(options, ...)`, are now empty; use `@root` directly;
- the one-line alternative to forward *all* enclosing `@` variables, and
why it was not chosen (outer loop variables would leak in).
Issue documented in dynamicComponent: The rendering is performed without any context forwarding from the primary render call. This has left requests to @root from within the context of a dynamicComponent render to have unexpected results.
Tests
-----
- spec/helpers/blockHelpersRoot.js (new): renders `[{{@root.title}}]`
inside all 59 registered block helpers (inside `{{#with nested}}`, so
page `T` cannot be confused with context `N`) on v3 and v4, with exact
outputs. A guard test fails if a new block helper is registered without
a case (`typeof` is excluded as a false positive). Against the original
code, block, partial, forEach, forIn, forOwn and iterate fail.
- spec/helpers/3p/array.js, spec/helpers/3p/object.js,
spec/helpers/block.js: `@ variable boundary` tables, run on v3 and v4,
labelled per case:
pierces: must reach the block body (@root at top level and when
nested, own @index/@key, hash vars, @../index, @../key,
@../first/@../last, @../../index, @../abc from an outer
block's hash, nested helpers);
blocked: must NOT reach it directly (outer @index/@key/@first/@last,
custom outer @vars, @data/@fn/@hash, inner frame changes
leaking back out, #block hash args leaking out);
gotcha: documented surprises (hash `root` shadows @root).
Single-case specs for @root, hash vars and scope shifts were also added
to the existing describe blocks.
- Looping cases prefix each render with an `l{n}:` marker (n = the
iteration's @index/@key/item; nested loops list each level outer-first,
e.g. `l0x1:`) and end each render with a single space, so the expected
output reads as one chunk per iteration, e.g. `l0:T l1:T `.
ol/ul are already split per item by `</li>\n<li>` and get no space.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What? Why?
I noticed that
@rootwas not supported in page fragments, so with a bit of poking from claude I've put together a proposal solution that I'm fairly certain shouldn't have any side effects against currently deployed themes but that would need to be validated. Yes whist I did have assistance from claude to generate the tests and inline comments, I did review everything by hand and I have confidence in the results of the change. There is a documented deviance from the spec of adding@rootto dynamic partials as this will possibly change behaviour in some themes due to@rootbeing mapped tothisin the partial due to existing implementation using the structure for the mapping as context. Due to this I have left the dynamicComponent structure alone and added a deviation note to it for future reference, if needed, advertising why this behaviour is different.Ref Existing Issue: #177
How was it tested?
Test cases are included, but given that this is a variance from current structures, I have no way of knowing or even being able to validate how this would affect the wider ecosystem of themes. Someone may have a work around that this change, whist handlebars-spec correct, may break as it was not "standard" in the bigcommerce implementation due to that boundary at
#blockand#partialfor call back context.cc @bigcommerce/storefront-team
Commit Below:
{{@root}}rendered empty inside several custom block helpers because they replaced the Handlebars private-variable (@) frame with an object that had norootkey. Handlebars itself handles@rootcorrectly; the built-in helpers derive child frames fromoptions.data.Root causes
partial(this, { data: options.hash }), so the frame was only the hash args. Every{{#partial}}body rendered via{{#block}}/{{{block}}}, and every block fallback, lost@root(and every other@variable). Stencil themes wrap most page content this way.forEach) and helpers/3p/object.js (forIn,forOwn) calledutils.createFrame(options, options.hash), copying the options object (fn,inverse,hash,data, ...) instead ofoptions.data.iteratedelegates toforEach/forOwn, so it was broken too.Fix: forward only
@root, link _parent for scope shifts ----------------------------------------------------- Each affected helper now builds its frame as:@rootis forwarded; it is the page context everywhere.@variable (@index,@key,@first,@last, custom hash vars) is visible directly inside the block._parentpoints at the real enclosing frame, so explicit scope shifts (@../index,@../../index,@../key) resolve to the parent. For a{{#partial}}body, that parent is the{{#block}}call site.@variables ({{#block "x" foo=1}}gives@foo).Each call site carries a comment documenting the side effects:
root(e.g.root="x") shadows@root;@data.root,@fn,@hash, which leaked through by accident fromcreateFrame(options, ...), are now empty; use@rootdirectly;@variables, and why it was not chosen (outer loop variables would leak in).Issue documented in dynamicComponent: The rendering is performed without any context forwarding from the primary render call. This has left requests to
@rootfrom within the context of a dynamicComponent render to have unexpected results.Tests
[{{@root.title}}]inside all 59 registered block helpers (inside{{#with nested}}, so pageTcannot be confused with contextN) on v3 and v4, with exact outputs. A guard test fails if a new block helper is registered without a case (typeofis excluded as a false positive). Against the original code, block, partial, forEach, forIn, forOwn and iterate fail.@ variable boundarytables, run on v3 and v4, labelled per case: pierces: must reach the block body (@root at top level and when nested, own @index/@key, hash vars, @../index, @../key, @../first/@../last, @../../index, @../abc from an outer block's hash, nested helpers); blocked: must NOT reach it directly (outer @index/@key/@first/@last, custom outer @vars, @data/@fn/@hash, inner frame changes leaking back out, #block hash args leaking out); gotcha: documented surprises (hashrootshadows @root). Single-case specs for @root, hash vars and scope shifts were also added to the existing describe blocks.l{n}:marker (n = the iteration's @index/@key/item; nested loops list each level outer-first, e.g.l0x1:) and end each render with a single space, so the expected output reads as one chunk per iteration, e.g.l0:T l1:T. ol/ul are already split per item by</li>\n<li>and get no space.