@barefootjs/rust 0.31.1 → 0.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/rust",
3
- "version": "0.31.1",
3
+ "version": "0.31.3",
4
4
  "description": "minijinja (Rust) adapter for BarefootJS — compiles IR to .j2 templates and ships a Rust rendering runtime (packages/adapter-rust/runtime/); runs under any Rust web framework (axum, etc.)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,7 +54,7 @@
54
54
  "directory": "packages/adapter-rust"
55
55
  },
56
56
  "dependencies": {
57
- "@barefootjs/shared": "0.31.1"
57
+ "@barefootjs/shared": "0.31.3"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@barefootjs/jsx": ">=0.2.0",
@@ -71,9 +71,9 @@
71
71
  },
72
72
  "devDependencies": {
73
73
  "@barefootjs/adapter-tests": "0.1.0",
74
- "@barefootjs/jsx": "0.31.1",
75
- "@barefootjs/vite": "0.31.1",
76
- "@barefootjs/client": "0.31.1",
74
+ "@barefootjs/jsx": "0.31.3",
75
+ "@barefootjs/vite": "0.31.3",
76
+ "@barefootjs/client": "0.31.3",
77
77
  "typescript": "^5.0.0",
78
78
  "vite": "^6.0.0"
79
79
  }
@@ -12,7 +12,7 @@
12
12
 
13
13
  use barefootjs::backend_minijinja;
14
14
  use barefootjs::num::JsValue;
15
- use barefootjs::runtime::{js_to_mj, BfInstance, ChildRendererSpec, RenderSession};
15
+ use barefootjs::runtime::{BfInstance, ChildRendererSpec, RenderSession};
16
16
  use barefootjs::search_params::SearchParams;
17
17
  use minijinja::value::Value as MjValue;
18
18
  use serde::Deserialize;
@@ -96,11 +96,12 @@ fn run(payload_path: &str) -> Result<String, String> {
96
96
  ChildRendererSpec {
97
97
  component_name: child.name.clone(),
98
98
  template: child.template.clone(),
99
- // Converted from JSON to `Value` HERE, at registration time
100
- // -- `render_child` keeps props as `Value` end-to-end from
101
- // this point on (see `ChildRendererSpec::ssr_defaults`'s
102
- // docstring).
103
- ssr_defaults: js_to_mj(&decode_value(&child.ssr_defaults)),
99
+ // Kept RAW (`JsValue`, the FULL `{value, propName?,
100
+ // isRestProps?}` shape) -- `render_child` resolves this
101
+ // PER-CALL against the real caller props via
102
+ // `derive_stash_from_defaults` (see `ChildRendererSpec::
103
+ // ssr_defaults`'s docstring; #2524).
104
+ ssr_defaults: decode_value(&child.ssr_defaults),
104
105
  rest_props_name: child.rest_props_name.clone(),
105
106
  param_names: child.param_names.clone(),
106
107
  },
@@ -33,22 +33,34 @@
33
33
  //! REGISTER a [`ChildRendererSpec`] per entry: [`crate::runtime::BfInstance::
34
34
  //! render_child`] already implements that whole contract generically for
35
35
  //! every registered child (scope-id chaining, `_bf_slot`/`key` popping,
36
- //! rest-bag routing, ssrDefaults-then-caller-props merge -- see its
37
- //! docstring), because it was built to serve `bf-render`'s payload-driven
38
- //! children the same way. Registering a manifest entry is therefore just:
39
- //! derive the registry key + template name from `markedTemplate`, flatten
40
- //! `ssrDefaults` to its static fallback values via
41
- //! [`derive_stash_from_defaults`] (called with EMPTY props, since the
42
- //! caller-props override Python's closure applies per-call is already
43
- //! applied generically, once, inside `render_child`), and derive
44
- //! `rest_props_name`/`param_names` from which `ssrDefaults` entries carry
45
- //! `isRestProps`/`propName` (see `packages/jsx/src/ssr-defaults.ts`'s
46
- //! `extractSsrDefaults`, which only ever sets `propName` to the entry's OWN
47
- //! key -- so this static-then-override merge order is exactly equivalent
48
- //! to Python's per-call `props.get(propName, value)` derivation).
36
+ //! rest-bag routing, `derive_stash_from_defaults`-then-caller-props merge --
37
+ //! see its docstring), because it was built to serve `bf-render`'s
38
+ //! payload-driven children the same way. Registering a manifest entry is
39
+ //! therefore just: derive the registry key + template name from
40
+ //! `markedTemplate`, keep `ssrDefaults` RAW (the FULL `{value, propName?,
41
+ //! isRestProps?}` shape, with any STATIC `signal_init` override applied as
42
+ //! a bare replacement value), and derive `rest_props_name`/`param_names`
43
+ //! from which `ssrDefaults` entries carry `isRestProps`/`propName`.
44
+ //!
45
+ //! `ssrDefaults` is stored RAW, not flattened, and resolved PER-CALL inside
46
+ //! `render_child` against the caller's ACTUAL props (#2524). An earlier
47
+ //! version of this function flattened via [`derive_stash_from_defaults`]
48
+ //! called with an EMPTY props document AT REGISTRATION TIME -- on the
49
+ //! (false) assumption that every `propName` equals its own entry's key, so
50
+ //! resolving early against no props and resolving later against the real
51
+ //! caller props would agree. That assumption breaks for an ALIASED
52
+ //! destructured prop (`{ n: count }`, see `packages/jsx/src/ssr-defaults.ts`'s
53
+ //! `extractSsrDefaults`): the entry's own key is the LOCAL binding
54
+ //! (`count`), but `propName` is the CALLER-facing name (`n`) -- resolving
55
+ //! against an empty props document can only ever produce the static
56
+ //! fallback, silently discarding whatever the caller actually passed as
57
+ //! `n`. `param_names` has the matching fix: it carries the CALLER-facing
58
+ //! `propName`, not the entry's own (local) key, since `render_child`'s
59
+ //! rest-bag "keep" set compares against child props keyed by whatever the
60
+ //! calling template passed.
49
61
 
50
62
  use crate::num::JsValue;
51
- use crate::runtime::{js_to_mj, ChildRendererSpec, RenderSession};
63
+ use crate::runtime::{ChildRendererSpec, RenderSession};
52
64
  use std::collections::{BTreeMap, HashMap};
53
65
  use std::sync::Arc;
54
66
 
@@ -84,12 +96,17 @@ pub fn to_template_name(component_name: &str) -> String {
84
96
  /// rest-props bag the caller may have already assembled), else the
85
97
  /// static `value` fallback (normally `{}`).
86
98
  /// * `propName` set -- prefer `props[propName]` when present AND not
87
- /// `null`/`undefined`, else the static `value` fallback. Every
88
- /// `propName` the TS extractor emits equals its own entry's key (see
89
- /// `extractSsrDefaults`), so this always reads back the SAME key it
90
- /// writes -- a caller with no relevant props (this module's own
91
- /// registration path, below) can pass an empty `props` document to get
92
- /// pure static fallbacks.
99
+ /// `null`/`undefined`, else the static `value` fallback. For an
100
+ /// UN-aliased prop `propName` equals the entry's own key; for an
101
+ /// ALIASED destructured prop (`{ n: count }`, see
102
+ /// `packages/jsx/src/ssr-defaults.ts`'s `extractSsrDefaults`) the
103
+ /// entry's key is the LOCAL binding (`count`) but `propName` is the
104
+ /// CALLER-facing name (`n`) -- callers MUST pass the real caller props
105
+ /// (not an empty document) for this branch to do anything useful (see
106
+ /// `render_child`, which calls this per-call against the actual caller
107
+ /// props; this module's own registration path keeps `ssrDefaults` RAW
108
+ /// and defers to that per-call resolution rather than resolving early
109
+ /// against an empty `props` document -- #2524).
93
110
  /// * neither set -- the static `value` (a signal/memo's default,
94
111
  /// internal to the component, never sourced from `props`).
95
112
  pub fn derive_stash_from_defaults(defaults: &JsValue, props: &JsValue) -> JsValue {
@@ -204,17 +221,49 @@ pub fn register_components_from_manifest(
204
221
  let template_name = strip_manifest_template_path(marked);
205
222
  let registry_key = to_template_name(&component_name);
206
223
 
207
- let empty_props = JsValue::Object(BTreeMap::new());
208
224
  let ssr_defaults = entry_obj.get("ssrDefaults").cloned().unwrap_or_else(|| JsValue::Object(BTreeMap::new()));
209
- let mut defaults = derive_stash_from_defaults(&ssr_defaults, &empty_props);
225
+
226
+ // Apply any STATIC `signal_init` overrides by REPLACING the
227
+ // overridden key's entry with the bare override value --
228
+ // `resolve_child_vars`'s non-object-entry branch (`runtime.rs`) uses
229
+ // a bare value as-is, unconditionally (ignoring caller props), so an
230
+ // overridden key now WINS over a same-named caller prop. This is a
231
+ // DELIBERATE FLIP from the prior contract, not a preservation of it:
232
+ // previously this function flattened `ssr_defaults` against an EMPTY
233
+ // props document at registration time and `render_child` applied the
234
+ // caller's props LAST (`vars = defaults; vars.extend(props)`), so a
235
+ // caller prop always beat a `signal_init` override. Now
236
+ // `render_child` applies the RESOLVED extras last (`vars = props;
237
+ // vars.extend(extra)`), so an override -- and any `propName` that
238
+ // resolves against the real caller props -- wins instead. This
239
+ // matches the Python/PHP/Perl ports, where `signal_init_fn`'s
240
+ // returned `extra` (or the propName-resolved `_derive_stash_from_
241
+ // defaults` output) is merged LAST over `props`
242
+ // (`{**props, **extra}` / `props.merge(extra)`) rather than first.
243
+ // Resolution against the REAL caller props now happens PER-CALL,
244
+ // inside `render_child`, against these RAW (un-flattened) defaults
245
+ // -- registering with an EMPTY props document at THIS point (the
246
+ // pre-#2524-fix shape) is exactly the production bug: it flattened
247
+ // via `derive_stash_from_defaults` before any caller was known,
248
+ // discarding `propName` so an aliased destructured prop's
249
+ // CALLER-facing key could never later resolve.
250
+ let mut defaults_map = match &ssr_defaults {
251
+ JsValue::Object(m) => m.clone(),
252
+ _ => BTreeMap::new(),
253
+ };
210
254
  if let Some(overrides) = signal_init.get(&registry_key) {
211
- if let (JsValue::Object(base), Some(over)) = (&mut defaults, overrides.as_object()) {
255
+ if let Some(over) = overrides.as_object() {
212
256
  for (k, v) in over {
213
- base.insert(k.clone(), v.clone());
257
+ defaults_map.insert(k.clone(), v.clone());
214
258
  }
215
259
  }
216
260
  }
261
+ let defaults = JsValue::Object(defaults_map);
217
262
 
263
+ // `param_names` carries the CALLER-facing name (`propName`), not the
264
+ // entry's own (local) key -- `render_child`'s rest-bag "keep" set
265
+ // compares against child props keyed by whatever the calling
266
+ // template passed, not the child's local binding (#2524).
218
267
  let (rest_props_name, param_names) = match ssr_defaults.as_object() {
219
268
  Some(m) => {
220
269
  let mut rest = None;
@@ -223,8 +272,8 @@ pub fn register_components_from_manifest(
223
272
  let Some(dm) = d.as_object() else { continue };
224
273
  if matches!(dm.get("isRestProps"), Some(JsValue::Bool(true))) {
225
274
  rest = Some(name.clone());
226
- } else if dm.contains_key("propName") {
227
- params.push(name.clone());
275
+ } else if let Some(prop_name) = dm.get("propName").and_then(|v| v.as_str()) {
276
+ params.push(prop_name.to_string());
228
277
  }
229
278
  }
230
279
  (rest, params)
@@ -237,7 +286,7 @@ pub fn register_components_from_manifest(
237
286
  ChildRendererSpec {
238
287
  component_name: template_name.clone(),
239
288
  template: template_name,
240
- ssr_defaults: js_to_mj(&defaults),
289
+ ssr_defaults: defaults,
241
290
  rest_props_name,
242
291
  param_names,
243
292
  },
@@ -796,13 +796,19 @@ pub struct ChildRendererSpec {
796
796
  pub component_name: String,
797
797
  /// snake_case `.j2` template base name.
798
798
  pub template: String,
799
- /// Static ssrDefaults values (already flattened to plain values, NOT
800
- /// the `{value, propName, isRestProps}` wrapper shape -- see the module
801
- /// docstring's note on why `_derive_stash_from_defaults` isn't ported).
802
- /// Converted from JSON to `Value` at REGISTRATION time (`bf-render.rs`)
803
- /// -- see `render_child`'s docstring on why child props stay `Value`
804
- /// end-to-end rather than round-tripping through `JsValue`.
805
- pub ssr_defaults: MjValue,
799
+ /// Static ssrDefaults, RAW -- the FULL `{value, propName?, isRestProps?}`
800
+ /// wrapper shape (or a bare value for a propName-less entry), exactly as
801
+ /// `extractSsrDefaults` emits it. Resolved PER-CALL against the real
802
+ /// caller props by [`crate::manifest::derive_stash_from_defaults`] inside
803
+ /// `render_child` below -- NOT flattened at registration time (that was
804
+ /// the #2524 production bug: flattening early, before the caller's props
805
+ /// are known, discards `propName` and so can never resolve an aliased
806
+ /// destructured prop's CALLER-facing key onto its local template var).
807
+ /// Kept as `JsValue` (not `MjValue`) because `derive_stash_from_defaults`
808
+ /// operates in the JSON-shaped `JsValue` domain; converted to `MjValue`
809
+ /// only for the resolved OUTPUT (see `render_child`'s docstring on why
810
+ /// child props otherwise stay `Value` end-to-end).
811
+ pub ssr_defaults: JsValue,
806
812
  pub rest_props_name: Option<String>,
807
813
  pub param_names: Vec<String>,
808
814
  }
@@ -1115,11 +1121,32 @@ impl BfInstance {
1115
1121
  data_key,
1116
1122
  };
1117
1123
 
1118
- // Seed template vars: static ssrDefaults first, caller's props win
1119
- // -- mirrors buildChildRenderers' `_vars = {**_defaults, **child_props}`.
1120
- let mut vars: BTreeMap<String, MjValue> =
1121
- mj_map_to_btreemap(&spec.ssr_defaults).into_iter().map(|(k, v)| (mangle_ident(&k), v)).collect();
1122
- vars.extend(map);
1124
+ // Seed template vars through `resolve_child_vars`, resolving each
1125
+ // entry's `propName` against the REAL (already-mangled) caller props
1126
+ // -- mirrors the Ruby/Python/PHP/Perl runtime ports'
1127
+ // `derive_vars_from_defaults` / `_derive_stash_from_defaults` (#2524
1128
+ // SSR half: a flat `{**_defaults, **child_props}` merge never
1129
+ // resolves an aliased destructured prop's CALLER-facing key (`n`)
1130
+ // onto its local template var (`count`) -- `spec.ssr_defaults` is
1131
+ // the FULL `{value, propName?, isRestProps?}` shape, not flattened,
1132
+ // so this resolution has something to read). Caller props still win
1133
+ // for any key resolution did NOT touch (an extraneous, undeclared
1134
+ // prop); `extra` wins for the keys it DOES resolve -- the same
1135
+ // `props.merge(extra)` order every other language port uses.
1136
+ //
1137
+ // Deliberately NOT `crate::manifest::derive_stash_from_defaults`
1138
+ // (the `JsValue`-only free function): that would round-trip every
1139
+ // caller prop through `mj_to_js`/`js_to_mj`, which has no safe/
1140
+ // unsafe distinction and silently strips a SAFE `Value`'s flag (a
1141
+ // JSX children capture, `bf.string(...)`'d markup) -- exactly the
1142
+ // "children lost their safe flag" bug this method's docstring
1143
+ // above warns about. `resolve_child_vars` stays in the `MjValue`
1144
+ // domain for every caller-supplied value; only the STATIC fallback
1145
+ // (`d.value`, from the manifest/generated payload, never JSX
1146
+ // content) goes through `js_to_mj`.
1147
+ let extra = resolve_child_vars(&spec.ssr_defaults, &map);
1148
+ let mut vars: BTreeMap<String, MjValue> = map;
1149
+ vars.extend(extra);
1123
1150
 
1124
1151
  let rendered = backend_minijinja::render_named_from_state_values(state, &spec.template, child.as_mj_value(), vars)?;
1125
1152
  // chomp: remove at most one trailing newline.
@@ -1127,6 +1154,57 @@ impl BfInstance {
1127
1154
  }
1128
1155
  }
1129
1156
 
1157
+ /// `MjValue`-domain twin of [`crate::manifest::derive_stash_from_defaults`],
1158
+ /// used by [`BfInstance::render_child`] so a caller-supplied prop value --
1159
+ /// which may be a SAFE `Value` (a JSX children capture, `bf.string(...)`'d
1160
+ /// markup) -- is used AS-IS when `propName` resolves it, never round-tripped
1161
+ /// through the JSON-shaped `JsValue` domain (which has no safe/unsafe
1162
+ /// distinction and would silently strip that flag, double-escaping the
1163
+ /// child's HTML -- see `render_child`'s docstring). `ssr_defaults` is the
1164
+ /// RAW (un-flattened) `{value, propName?, isRestProps?}` shape; `props` is
1165
+ /// the caller's ALREADY-mangled prop map. Only the STATIC fallback
1166
+ /// (`d.value`, from the manifest/generated payload -- JSX prop values are
1167
+ /// never statically evaluable, so they're always `null` there) goes through
1168
+ /// [`js_to_mj`]. Returns keys already mangled (matching `props`'s own
1169
+ /// convention), ready to merge/overwrite directly.
1170
+ fn resolve_child_vars(ssr_defaults: &JsValue, props: &BTreeMap<String, MjValue>) -> BTreeMap<String, MjValue> {
1171
+ let mut extra = BTreeMap::new();
1172
+ let defaults_map = match ssr_defaults.as_object() {
1173
+ Some(m) => m,
1174
+ None => return extra,
1175
+ };
1176
+ for (name, d) in defaults_map {
1177
+ let key = mangle_ident(name);
1178
+ let dm = match d.as_object() {
1179
+ Some(m) => m,
1180
+ None => {
1181
+ // Bare (non-object) entry -- used as-is, mirrors every other
1182
+ // port's `ref($d) eq 'HASH'` / `isinstance(d, dict)` guard.
1183
+ extra.insert(key, js_to_mj(d));
1184
+ continue;
1185
+ }
1186
+ };
1187
+ let fallback = || js_to_mj(&dm.get("value").cloned().unwrap_or(JsValue::Null));
1188
+ if matches!(dm.get("isRestProps"), Some(JsValue::Bool(true))) {
1189
+ // The rest bag is already assembled (as a real `Value`, member
1190
+ // safe flags intact) under its own mangled key by the rest-bag
1191
+ // routing above -- prefer that live value over the static
1192
+ // fallback.
1193
+ let v = props.get(&key).cloned().unwrap_or_else(fallback);
1194
+ extra.insert(key, v);
1195
+ continue;
1196
+ }
1197
+ let prop_name = dm.get("propName").and_then(|v| v.as_str());
1198
+ let from_props = prop_name.and_then(|pn| props.get(&mangle_ident(pn)));
1199
+ let v = match from_props {
1200
+ Some(pv) if !matches!(pv.kind(), ValueKind::None | ValueKind::Undefined) => pv.clone(),
1201
+ _ => fallback(),
1202
+ };
1203
+ extra.insert(key, v);
1204
+ }
1205
+ extra
1206
+ }
1207
+
1130
1208
  // ---------------------------------------------------------------------------
1131
1209
  // minijinja::Value <-> JsValue conversion. minijinja 2.x represents EVERY
1132
1210
  // sequence/mapping as a `Object` (`ObjectRepr::Seq`/`Map`/`Iterable`) --
@@ -146,6 +146,41 @@ fn register_from_flat_manifest_applies_signal_init_override() {
146
146
  assert_eq!(html.trim(), "9");
147
147
  }
148
148
 
149
+ /// A `signal_init` override wins over a CALLER-SUPPLIED prop of the same
150
+ /// name, not just the manifest's static default (#2524 precedence flip --
151
+ /// see `manifest.rs`'s registration-site comment). Pre-#2524 this function
152
+ /// flattened `ssr_defaults` at registration time and `render_child` applied
153
+ /// the caller's props LAST, so a caller-supplied `level` would have beaten
154
+ /// the override; now `render_child` applies the resolved `signal_init`
155
+ /// override LAST, matching the Python/PHP/Perl ports' `{**props, **extra}`
156
+ /// / `props.merge(extra)` merge order.
157
+ #[test]
158
+ fn register_from_flat_manifest_override_wins_over_caller_prop() {
159
+ let dir = TempDir::new("override-vs-caller");
160
+ dir.write("root.j2", "{{ bf.render_child('widget', {'level': 3}) | safe }}");
161
+ dir.write("Widget.j2", "{{ bf.string(level) }}");
162
+
163
+ let manifest = obj(vec![(
164
+ "Widget",
165
+ obj(vec![
166
+ ("markedTemplate", JsValue::String("templates/Widget.j2".into())),
167
+ ("ssrDefaults", obj(vec![("level", obj(vec![("value", JsValue::Number(1.0))]))])),
168
+ ]),
169
+ )]);
170
+
171
+ let mut signal_init = HashMap::new();
172
+ signal_init.insert("widget".to_string(), obj(vec![("level", JsValue::Number(9.0))]));
173
+
174
+ let session = RenderSession::new();
175
+ register_components_from_manifest(&session, &manifest, &signal_init);
176
+
177
+ let env = backend_minijinja::build_environment(&dir.0);
178
+ let root = BfInstance::root(Arc::clone(&session), "test".to_string());
179
+ let html = backend_minijinja::render_named(&env, "root", root.as_mj_value(), &JsValue::Object(BTreeMap::new())).unwrap();
180
+ // The caller passed `level: 3`, but the `signal_init` override (9) wins.
181
+ assert_eq!(html.trim(), "9");
182
+ }
183
+
149
184
  /// `ui/<name>/index`-shaped entries (the component-library registry
150
185
  /// convention Python's port targets) still register correctly.
151
186
  #[test]
@@ -14,9 +14,8 @@
14
14
  //! additionally exercises the actual `Object::call_method` dispatch path.
15
15
 
16
16
  use barefootjs::num::JsValue;
17
- use barefootjs::runtime::{js_to_mj, ChildRendererSpec};
17
+ use barefootjs::runtime::ChildRendererSpec;
18
18
  use barefootjs::{backend_minijinja, BfInstance, RenderSession};
19
- use minijinja::value::Value as MjValue;
20
19
  use std::collections::BTreeMap;
21
20
  use std::path::PathBuf;
22
21
  use std::sync::Arc;
@@ -46,10 +45,11 @@ fn no_defaults() -> JsValue {
46
45
  JsValue::Object(BTreeMap::new())
47
46
  }
48
47
 
49
- /// `ChildRendererSpec::ssr_defaults`, which is `minijinja::Value` (props
50
- /// stay `Value` end-to-end through `render_child` -- see its docstring).
51
- fn no_ssr_defaults() -> MjValue {
52
- MjValue::from(BTreeMap::<String, MjValue>::new())
48
+ /// `ChildRendererSpec::ssr_defaults`, which is a RAW `JsValue` (the FULL
49
+ /// `{value, propName?, isRestProps?}` shape, resolved per-call against the
50
+ /// real caller props inside `render_child` -- see its docstring).
51
+ fn no_ssr_defaults() -> JsValue {
52
+ JsValue::Object(BTreeMap::new())
53
53
  }
54
54
 
55
55
  #[test]
@@ -128,7 +128,7 @@ fn rest_bag_routing_and_ssr_defaults_merge() {
128
128
  ChildRendererSpec {
129
129
  component_name: "Card".to_string(),
130
130
  template: "card".to_string(),
131
- ssr_defaults: js_to_mj(&JsValue::Object(defaults)),
131
+ ssr_defaults: JsValue::Object(defaults),
132
132
  rest_props_name: Some("rest".to_string()),
133
133
  param_names: vec!["title".to_string()],
134
134
  },
@@ -138,6 +138,40 @@ fn rest_bag_routing_and_ssr_defaults_merge() {
138
138
  assert_eq!(out, "Hi|Sub|E|dark");
139
139
  }
140
140
 
141
+ #[test]
142
+ fn aliased_prop_resolves_callers_facing_key_onto_local_template_var() {
143
+ // #2524 (SSR half): a renaming destructure (`{ n: count }`) keys the
144
+ // ssrDefaults entry by the LOCAL binding (`count`) but its `propName`
145
+ // is the CALLER-facing key (`n`) -- the call site passes `n` (it has no
146
+ // visibility into how the child renames it internally), so
147
+ // `render_child` must resolve `n` onto the child template's `count`,
148
+ // not leave `count` at its static (`null`) fallback.
149
+ let dir = TempDir::new("aliased");
150
+ dir.write("root.j2", "{{ bf.render_child('badge', {'n': 7}) }}");
151
+ dir.write("badge.j2", "{{ count }}");
152
+
153
+ let env = backend_minijinja::build_environment(&dir.0);
154
+ let session = RenderSession::new();
155
+ let mut count_entry = BTreeMap::new();
156
+ count_entry.insert("value".to_string(), JsValue::Null);
157
+ count_entry.insert("propName".to_string(), JsValue::from("n"));
158
+ let mut defaults = BTreeMap::new();
159
+ defaults.insert("count".to_string(), JsValue::Object(count_entry));
160
+ session.register_child_renderer(
161
+ "badge".to_string(),
162
+ ChildRendererSpec {
163
+ component_name: "Badge".to_string(),
164
+ template: "badge".to_string(),
165
+ ssr_defaults: JsValue::Object(defaults),
166
+ rest_props_name: None,
167
+ param_names: vec!["n".to_string()],
168
+ },
169
+ );
170
+ let root = BfInstance::root(session, "Root_test");
171
+ let out = backend_minijinja::render_entry(&env, "root", root.as_mj_value(), &no_defaults(), &[]).unwrap();
172
+ assert_eq!(out, "7");
173
+ }
174
+
141
175
  #[test]
142
176
  fn loop_child_without_slot_gets_a_fresh_component_prefixed_scope() {
143
177
  let dir = TempDir::new("loopchild");
@@ -22,20 +22,4 @@ export const renderDivergences: RenderDivergences = {
22
22
  // instead of a fixed regex-shape catalogue) now correctly seeds `todos`
23
23
  // from `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`.
24
24
 
25
- // Onboarding TSX-fidelity fixtures (PR #2461): `expectedHtml` was
26
- // hand-authored to the CORRECT output while the emission bug lived in
27
- // the shared compiler layer — every adapter, including the Hono
28
- // reference, used to emit the broken form. That shared-layer defect
29
- // (#2460) is now FIXED (b4f5075): `expectedHtml` is generated from the
30
- // Hono reference like any other fixture. The remaining gap is
31
- // per-template-adapter — this adapter still keys its template vars /
32
- // ssr-defaults / props bridge off the local binding instead of
33
- // `sourceName ?? name` — tracked by #2524 (the 7 silent template
34
- // adapters). Graduate by applying the same `sourceName ?? name` fix to
35
- // this adapter's emission path and deleting these lines (and the
36
- // matching hono `skipJsx` entries, already gone).
37
- 'aliased-destructured-prop':
38
- 'aliased destructured prop `{ n: count }` loses its rename — template vars, ssr-defaults, and the props bridge all key off the local name, so the prop is always undefined (https://github.com/piconic-ai/barefootjs/issues/2524)',
39
- 'composite-row-child-aliased-prop':
40
- 'same defect as `aliased-destructured-prop` (#2524), inside a keyed `.map()` loop row: the nested child\'s renamed prop (`{ n: count }`) is always undefined, so both rows render an empty count (https://github.com/piconic-ai/barefootjs/issues/2524)',
41
25
  }
@@ -28,8 +28,8 @@
28
28
  * that closes that gap.
29
29
  */
30
30
 
31
- import { compileJSX, extractSsrDefaults, importsSearchParams, evaluateSignalInit } from '@barefootjs/jsx'
32
- import type { ComponentIR } from '@barefootjs/jsx'
31
+ import { compileJSX, extractSsrDefaults, deriveStashFromDefaults, importsSearchParams, evaluateSignalInit } from '@barefootjs/jsx'
32
+ import type { ComponentIR, SsrDefault } from '@barefootjs/jsx'
33
33
  import { mkdir, rm } from 'node:fs/promises'
34
34
  import { resolve } from 'node:path'
35
35
 
@@ -362,24 +362,30 @@ function collectImportedComponentNames(ir: ComponentIR): string[] {
362
362
  * design doc; see `runtime/src/backend_minijinja.rs`'s `render_child` for
363
363
  * the Rust-side closure — the near-verbatim structural counterpart of
364
364
  * adapter-jinja's `buildChildRenderers`, which instead emitted Python
365
- * closure SOURCE per child). `ssr_defaults` mirrors `ssrDefaultsToPy`:
366
- * only the static fallback `value` of each `{ value, propName?,
367
- * isRestProps? }` ssrDefaults entry is needed — the child renderer's
368
- * caller props always win over it.
365
+ * closure SOURCE per child). `ssr_defaults` is sent VERBATIM — `value` /
366
+ * `propName` / `isRestProps` intact so the Rust runtime's
367
+ * `derive_stash_from_defaults` (called per-call, inside `render_child`,
368
+ * against the REAL child props) can resolve an aliased destructured prop's
369
+ * CALLER-facing key (`n`) onto its local template var (`count`). Sending
370
+ * only the flattened static `value` here was the #2524 SSR-half bug — it
371
+ * left nothing for that resolution to read. `param_names` carries the
372
+ * CALLER-facing name (`sourceName ?? name`) per prop — `render_child`'s
373
+ * rest-bag "keep" set compares against child props keyed by whatever the
374
+ * calling template passed, not the child's local binding.
369
375
  */
370
376
  function buildChildrenPayload(
371
377
  childTemplates: Map<string, { template: string; ir: ComponentIR }>,
372
378
  ): Array<{
373
379
  name: string
374
380
  template: string
375
- ssr_defaults: Record<string, unknown>
381
+ ssr_defaults: Record<string, SsrDefault>
376
382
  rest_props_name: string | null
377
383
  param_names: string[]
378
384
  }> {
379
385
  const out: Array<{
380
386
  name: string
381
387
  template: string
382
- ssr_defaults: Record<string, unknown>
388
+ ssr_defaults: Record<string, SsrDefault>
383
389
  rest_props_name: string | null
384
390
  param_names: string[]
385
391
  }> = []
@@ -388,29 +394,14 @@ function buildChildrenPayload(
388
394
  out.push({
389
395
  name: componentName,
390
396
  template: toSnakeCase(componentName),
391
- ssr_defaults: ssrDefaultsToVars(ssrDefaults),
397
+ ssr_defaults: ssrDefaults,
392
398
  rest_props_name: childIR.metadata.restPropsName ?? null,
393
- param_names: (childIR.metadata.propsParams ?? []).map(p => p.name),
399
+ param_names: (childIR.metadata.propsParams ?? []).map(p => p.sourceName ?? p.name),
394
400
  })
395
401
  }
396
402
  return out
397
403
  }
398
404
 
399
- /** Reduce an ssrDefaults map to its static fallback values (plain JS object). */
400
- function ssrDefaultsToVars(defaults: Record<string, unknown>): Record<string, unknown> {
401
- const out: Record<string, unknown> = {}
402
- for (const [name, d] of Object.entries(defaults)) {
403
- // ssrDefaults entries are `{ value, propName?, isRestProps? }` or a
404
- // bare value. The child renderer's caller props win, so we only need
405
- // the static fallback `value` here.
406
- out[name] =
407
- d && typeof d === 'object' && 'value' in (d as Record<string, unknown>)
408
- ? (d as Record<string, unknown>).value
409
- : d
410
- }
411
- return out
412
- }
413
-
414
405
  /**
415
406
  * Convert PascalCase to snake_case for template naming (matches the
416
407
  * adapter's `toTemplateName`).
@@ -445,24 +436,31 @@ function buildVars(
445
436
  const vars: Record<string, unknown> = {}
446
437
 
447
438
  // Prop params with defaults (before signals, so signals can reference them).
439
+ // Seeded through the shared `deriveStashFromDefaults` (the TS twin of the
440
+ // production `derive_stash_from_defaults` the Rust runtime calls at
441
+ // render time for child components — see `render_child` in
442
+ // `runtime/src/runtime.rs`) so an aliased destructured prop's
443
+ // CALLER-facing key (`propName`, e.g. `n` for `{ n: count }`) is
444
+ // honoured, not just the local template var name (#2524 SSR half).
445
+ // `props` is keyed by the caller-facing name, exactly what `propName`
446
+ // resolves against.
447
+ const rootSsrDefaults = extractSsrDefaults(ir.metadata) ?? {}
448
+ const derivedProps = deriveStashFromDefaults(rootSsrDefaults, props ?? {})
448
449
  for (const param of ir.metadata.propsParams) {
449
- if (props && param.name in props) continue
450
- if (param.defaultValue) {
451
- const value = evaluateSignalInit(param.defaultValue.trim(), props)
452
- if (value !== null) {
453
- vars[param.name] = value
454
- continue
455
- }
456
- }
450
+ if (param.isRest) continue
457
451
  // No default + no caller value: pass `null` (Rust's `None`/minijinja
458
452
  // Undefined) so a bare reference to an optional prop doesn't fault
459
453
  // before its falsy branch elides.
460
- vars[param.name] = null
454
+ vars[param.name] = derivedProps[param.name] ?? null
461
455
  }
462
456
 
463
457
  // Route undeclared props into the rest bag (`bf.spread_attrs($<rest>)`).
464
458
  const restPropsName = ir.metadata.restPropsName
465
- const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
459
+ // Caller-facing keys an aliased destructured prop's DECLARED set for
460
+ // rest-bag routing must match what the caller actually sent (`n`), not
461
+ // the local binding (`count`), or the caller's own prop silently gets
462
+ // swept into the rest bag as an undeclared extra (#2524).
463
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.sourceName ?? p.name))
466
464
  const restBagEntries: Array<[string, unknown]> = []
467
465
  if (restPropsName && props) {
468
466
  for (const [key, value] of Object.entries(props)) {
@@ -477,11 +475,20 @@ function buildVars(
477
475
  vars[restPropsName] = Object.fromEntries(restBagEntries)
478
476
  }
479
477
 
480
- // User props.
478
+ // User props. Skip a key that's already a declared param's LOCAL template
479
+ // var name — `derivedProps` above already resolved that var correctly
480
+ // (through `propName`, for an aliased prop); re-assigning the raw
481
+ // `props[key]` here would silently clobber it with an UNRELATED value
482
+ // whenever a caller happens to also pass a same-spelled-as-local-name prop
483
+ // that isn't this param's actual `propName` (#2524 — surfaced by the
484
+ // aliased-destructured-prop generated data points, which pass both `n`
485
+ // (the real propName) and an incidental `count` key).
486
+ const localParamNames = new Set(ir.metadata.propsParams.map(p => p.name))
481
487
  if (props) {
482
488
  for (const [key, value] of Object.entries(props)) {
483
489
  if (key.startsWith('__')) continue
484
490
  if (routedKeys.has(key)) continue
491
+ if (localParamNames.has(key)) continue
485
492
  if (
486
493
  typeof value === 'string' ||
487
494
  typeof value === 'number' ||
@@ -507,11 +514,9 @@ function buildVars(
507
514
 
508
515
  // Memo values seeded from the statically-evaluated ssrDefaults, same
509
516
  // as the production plugin's before_render hook.
510
- const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
511
517
  for (const memo of ir.metadata.memos) {
512
- const entry = ssrDefaults[memo.name]
513
- const value = entry && typeof entry === 'object' && 'value' in entry ? entry.value : 0
514
- vars[memo.name] = value ?? 0
518
+ const entry = rootSsrDefaults[memo.name]
519
+ vars[memo.name] = entry ? (entry.value ?? 0) : 0
515
520
  }
516
521
 
517
522
  return vars