Skip to content

fix(helpers): forward @root across block helper data frames - #412

Open
34638a wants to merge 1 commit into
bigcommerce:masterfrom
34638a:fix/root-data-frame-in-block-helpers
Open

34638a wants to merge 1 commit into
bigcommerce:masterfrom
34638a:fix/root-data-frame-in-block-helpers

Conversation

@34638a

@34638a 34638a commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

What? Why?

I noticed that @root was 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 @root to dynamic partials as this will possibly change behaviour in some themes due to @root being mapped to this in 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 #block and #partial for 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 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.

`{{@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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant