From 73da8986c054edf7606b0dba1cb65d915c608ef7 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Sun, 19 Jul 2026 21:46:13 +0800 Subject: [PATCH] fix: try look up from blockcontext by name --- src/block.rs | 11 ++++ src/context.rs | 10 +++- src/partial.rs | 14 +++-- tests/issue_698.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 tests/issue_698.rs diff --git a/src/block.rs b/src/block.rs index 079d1e85e..32c0d3d84 100644 --- a/src/block.rs +++ b/src/block.rs @@ -69,6 +69,9 @@ pub struct BlockContext<'rc> { block_partials: BTreeMap, /// local variables in current context local_variables: LocalVars, + /// whether this block was pushed by a partial expansion. Block params do + /// not cross a partial boundary (matches Handlebars.js). See #698/#495. + is_partial_scope: bool, } impl<'rc> BlockContext<'rc> { @@ -149,4 +152,12 @@ impl<'rc> BlockContext<'rc> { pub fn set_block_param(&mut self, key: &'rc str, value: BlockParamHolder) { self.block_params.data.insert(key, value); } + + pub fn is_partial_scope(&self) -> bool { + self.is_partial_scope + } + + pub(crate) fn mark_partial_scope(&mut self) { + self.is_partial_scope = true; + } } diff --git a/src/context.rs b/src/context.rs index 504a6e3b8..fb014949f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -132,10 +132,14 @@ fn get_in_block_params<'a>( block_contexts: &'a VecDeque>, p: &str, ) -> Option<(&'a BlockParamHolder, &'a Vec)> { + // Stop at a partial boundary: block params don't cross it (matches + // Handlebars.js). See issues #495 and #698. for bc in block_contexts { - let v = bc.get_block_param(p); - if v.is_some() { - return v.map(|v| (v, bc.base_path())); + if let Some(v) = bc.get_block_param(p) { + return Some((v, bc.base_path())); + } + if bc.is_partial_scope() { + break; } } diff --git a/src/partial.rs b/src/partial.rs index 03d1b8895..09210cec2 100644 --- a/src/partial.rs +++ b/src/partial.rs @@ -83,12 +83,16 @@ pub fn expand_partial<'reg: 'rc, 'rc>( .map(|(k, v)| (*k, v.value())) .collect::>(); + // Hash params are merged into the partial's context below via + // `merge_json` (matching Handlebars.js `Utils.extend({}, context, + // options.hash)`), not registered as block params — otherwise they'd + // leak into nested `{{#each}}`/`{{#with}}` scopes (#698). + // + // Mark this block as a partial scope boundary so block params from + // the caller don't leak in either (matches Handlebars.js; fixes + // #495 where a hash param must override an enclosing block param). let mut partial_include_block = BlockContext::new(); - // overwrite parent block's params - for (name, value) in &hash_ctx { - partial_include_block - .set_block_param(name, crate::BlockParamHolder::Value((*value).clone())); - } + partial_include_block.mark_partial_scope(); // inherit local variables (@key/@index/@first/@last) from the // enclosing block so they remain accessible inside the partial. diff --git a/tests/issue_698.rs b/tests/issue_698.rs new file mode 100644 index 000000000..4e42b55e0 --- /dev/null +++ b/tests/issue_698.rs @@ -0,0 +1,146 @@ +use handlebars::Handlebars; +use handlebars::testing::TestHandlebars; +use serde_json::json; + +// Regression test for https://github.com/sunng87/handlebars-rust/issues/698 +// +// When a partial is called with BOTH a positional param (`this`) AND hash +// params (`title="..."`), and the partial body contains an `{{#each}}` over +// an array of objects, field lookups inside the each block must resolve +// against the current item, not the hash-injected value. +// +// This was a regression introduced in v6.4.1 by commit 91d585f ("fix: block +// scoped inline", PR #733): that change started registering partial hash +// params as block params on the partial's block context, while also keeping +// the partial block on the render stack (push_block instead of +// replace_blocks). Because `get_in_block_params` walks the entire block +// stack, the partial-level hash block param leaked into nested `{{#each}}` +// scopes and shadowed each item's own fields. + +#[test] +fn test_hash_param_does_not_shadow_each_item_field() { + let mut hbs = Handlebars::new(); + hbs.register( + "header", + "
    {{#each top_nav}}
  • {{title}}
  • {{/each}}
", + ); + hbs.register("page", "{{> header this title=\"PAGE TITLE\"}}"); + hbs.assert_render( + "page", + &json!({"top_nav": [{"title": "Downloads"}, {"title": "News"}, {"title": "Blog"}]}), + "
  • Downloads
  • News
  • Blog
", + ); +} + +#[test] +fn test_hash_param_does_not_shadow_each_value_with_block_param() { + // Same scenario using block-param syntax in the each loop. + let mut hbs = Handlebars::new(); + hbs.register( + "header", + "
    {{#each top_nav as |item|}}
  • {{item.title}}
  • {{/each}}
", + ); + hbs.register("page", "{{> header this title=\"PAGE TITLE\"}}"); + hbs.assert_render( + "page", + &json!({"top_nav": [{"title": "Downloads"}, {"title": "News"}, {"title": "Blog"}]}), + "
  • Downloads
  • News
  • Blog
", + ); +} + +#[test] +fn test_hash_param_does_not_shadow_with_block_field() { + // Same class of bug, but with {{#with}} instead of {{#each}}: a hash + // param on the partial must not leak into a {{#with}} scope and shadow + // the with target's fields. + let mut hbs = Handlebars::new(); + hbs.register("header", "[{{#with item}}{{title}}{{/with}}]"); + hbs.register("page", "{{> header this title=\"PAGE TITLE\"}}"); + hbs.assert_render( + "page", + &json!({"item": {"title": "Inner Title"}}), + "[Inner Title]", + ); +} + +#[test] +fn test_hash_param_still_accessible_at_partial_root() { + // The hash param must still be accessible at the partial's top level + // (where there is no inner each/with scope shadowing it). This guards + // against over-fixing the bug by dropping hash params entirely. + let mut hbs = Handlebars::new(); + hbs.register("header", "title={{title}}"); + hbs.register("page", "{{> header this title=\"PAGE TITLE\"}}"); + hbs.assert_render("page", &json!({}), "title=PAGE TITLE"); +} + +#[test] +fn test_hash_param_does_not_leak_as_fallback_in_each() { + // When a hash param collides with a field name that the iterated items + // do NOT have, Handlebars.js renders empty (the hash value is scoped to + // the partial's own frame and does not fall back into nested scopes). + // Registering the hash as a block param would incorrectly leak it as a + // fallback here. This test pins the Handlebars.js-compatible behavior. + let mut hbs = Handlebars::new(); + hbs.register( + "header", + "
    {{#each items}}
  • {{title}}
  • {{/each}}
", + ); + hbs.register("page", "{{> header items=list title=\"DEFAULT\"}}"); + hbs.assert_render( + "page", + &json!({"list": [{"name": "a"}, {"name": "b"}]}), + "
", + ); +} + +#[test] +fn test_positional_only_partial_each_still_works() { + // Sanity check: a partial called with only a positional param (no hash) + // must continue to resolve each-item fields correctly. + let mut hbs = Handlebars::new(); + hbs.register( + "header", + "
    {{#each top_nav}}
  • {{title}}
  • {{/each}}
", + ); + hbs.register("page", "{{> header this }}"); + hbs.assert_render( + "page", + &json!({"top_nav": [{"title": "Downloads"}, {"title": "News"}]}), + "
  • Downloads
  • News
", + ); +} + +#[test] +fn test_caller_block_param_does_not_leak_into_partial() { + // An enclosing `{{#each ... as |name|}}` block param must NOT be visible + // inside a partial called from that scope. Handlebars.js compiles each + // partial independently, so `{{name}}` inside the partial is a plain + // context lookup that never reaches the caller's block param. With no + // hash and a string item (no `name` field), it renders empty. + let mut hbs = Handlebars::new(); + hbs.register("displayName", "[{{name}}]"); + hbs.register("t", "{{#each data as |name|}}{{>displayName}}{{/each}}"); + hbs.assert_render("t", &json!({"data": ["hudel", "test"]}), "[][]"); +} + +#[test] +fn test_outer_block_param_wins_over_inner_field_without_partial() { + // The #698 fix must not over-generalize: when there is NO partial in + // play, an outer declared block param still wins over an inner context + // field of the same name. This matches Handlebars.js, where `{{foo}}` + // compiles to a block-param access because `foo` is declared (`as |foo|`). + let mut hbs = Handlebars::new(); + hbs.register( + "t", + "{{#each items as |foo|}}[{{#with inner}}{{foo.value}}{{/with}}]{{/each}}", + ); + // `foo` (block param) = each item = {value: "ITEM_VAL", inner: {foo: "X"}}. + // `{{foo.value}}` navigates the block param (item.value), NOT + // inner.foo (which would be "X" / undefined as a .value nav). + hbs.assert_render( + "t", + &json!({"items": [{"value": "ITEM_VAL", "inner": {"foo": "X"}}]}), + "[ITEM_VAL]", + ); +}