Skip to content

Commit bd9b16a

Browse files
janechuCopilot
andcommitted
Treat missing f-repeat lists as empty
Render zero iterations when an f-repeat list binding is absent while preserving errors for present non-array values. Update Rust and Node tests plus docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8473aaa commit bd9b16a

7 files changed

Lines changed: 69 additions & 14 deletions

File tree

crates/microsoft-fast-build/DESIGN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Both return `RenderError::EmptyBinding` for blank expressions and `RenderError::
152152
Callers decide how to handle unresolved values:
153153
- Content bindings (`{{expr}}` / `{{{expr}}}`) render an empty string.
154154
- HTML attribute bindings omit the entire attribute.
155-
- `<f-repeat>` still requires its list binding to resolve to an array; a missing list binding returns `RenderError::MissingState`.
155+
- `<f-repeat>` treats a missing list binding as an empty array and renders zero iterations; present non-array values return `RenderError::NotAnArray`.
156156
- `<f-when>` evaluates a missing binding as falsy.
157157

158158
### Loop variable scoping
@@ -198,7 +198,7 @@ Because `||` is sought before `&&`, the recursive split on `||` runs first. Each
198198

199199
1. Extracts inner HTML and end position (same as `render_when`).
200200
2. Parses `value="{{item in items}}"` with `parse_repeat_expr` — expects exactly three whitespace-separated tokens where the middle is `"in"`.
201-
3. Resolves the list expression. Returns `RenderError::NotAnArray` if the value is not a `JsonValue::Array`.
201+
3. Resolves the list expression. Missing values are treated as an empty array. Present non-array values return `RenderError::NotAnArray`.
202202
4. For each item in the array, pushes `(var_name, item)` onto a new `loop_vars` vec and calls `render_node` on the inner template.
203203
5. Uses `Iterator::collect::<Result<String, _>>()` to short-circuit on the first error in any iteration.
204204

@@ -557,6 +557,6 @@ A hand-rolled recursive-descent parser. No external crates.
557557

558558
**Atomic tag processing for attribute bindings.** When a plain HTML opening tag in the literal region contains `{{expr}}` attribute values, those values are resolved and `data-fe-c` is injected into the tag as a whole before `next_directive` ever sees them. This prevents the `{{expr}}` inside attributes from being mistaken for content bindings. The cost is that `next_directive` is called once extra per tag iteration, but tags are short and rare enough that this has no meaningful performance impact.
559559

560-
**`Result` throughout.** All render functions return `Result<_, RenderError>`. Errors propagate via `?` for malformed templates, invalid JSON, invalid repeat expressions, and missing/invalid required directive state. Missing optional values are handled by binding context: content bindings render empty output, attribute bindings omit the attribute, `<f-when>` treats the value as falsy, and `<f-repeat>` still errors when its list binding is missing or not an array.
560+
**`Result` throughout.** All render functions return `Result<_, RenderError>`. Errors propagate via `?` for malformed templates, invalid JSON, invalid repeat expressions, and invalid directive state. Missing optional values are handled by binding context: content bindings render empty output, attribute bindings omit the attribute, `<f-when>` treats the value as falsy, and `<f-repeat>` treats a missing list binding as an empty array while still erroring when a present value is not an array.
561561

562562
**Left-to-right, first-match scanning.** Directives are found by searching for their literal opening strings. The earliest position wins. This is O(n×d) where n is the template length and d is the number of directive types — acceptable for the template sizes this crate targets.

crates/microsoft-fast-build/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ When `value` is a bare reference (e.g. `{{items}}`), the value is coerced to a b
221221
```
222222

223223
Inside `<f-repeat>`, `{{item}}` resolves to the current loop variable and `{{$index}}` resolves to the 0-based iteration index. Other bindings fall back to the root state (e.g. `{{title}}` above).
224+
If the list binding or dot path is missing, `<f-repeat>` treats it as an empty array and renders zero iterations. Present non-array values still return `RenderError::NotAnArray`.
224225

225226
```html
226227
<f-repeat value="{{row in rows}}">
@@ -594,7 +595,7 @@ All render functions return `Result<String, RenderError>`. `RenderError` is an e
594595
| `UnclosedBinding` | `{{` with no closing `}}` |
595596
| `UnclosedUnescapedBinding` | `{{{` with no closing `}}}` |
596597
| `EmptyBinding` | `{{}}` — blank expression |
597-
| `MissingState` | `<f-repeat>` list binding is absent from state |
598+
| `MissingState` | Required directive state is absent; missing `<f-repeat>` lists render zero iterations instead |
598599
| `UnclosedDirective` | `<f-when>` / `<f-repeat>` with no matching close tag |
599600
| `MissingValueAttribute` | Directive missing `value="{{…}}"` attribute |
600601
| `InvalidRepeatExpression` | Repeat value not in `item in list` format |
@@ -606,7 +607,7 @@ All render functions return `Result<String, RenderError>`. `RenderError` is an e
606607
Every error message includes a description of the problem and a snippet of the template near the error site to aid debugging:
607608

608609
```
609-
missing state: '{{items}}' has no matching key in the provided state — template: "…<f-repeat value=\"{{item in items}}\">…"
610+
type error: '{{items}}' must resolve to a JSON array for use in <f-repeat> — template: "…<f-repeat value=\"{{item in items}}\">…"
610611
unclosed binding '{{name': no closing '}}' found to end the expression — template: "Hello {{name"
611612
duplicate template: element '<my-button>' is defined in multiple files: ./a/my-button.html, ./b/my-button.html
612613
```

crates/microsoft-fast-build/src/directive.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ pub fn render_repeat(
143143
context: template_context(template, at),
144144
})?;
145145
match resolve_value(&list_expr, root, loop_vars) {
146-
None => Err(RenderError::MissingState {
147-
binding: list_expr,
148-
context: template_context(template, at),
149-
}),
146+
None => {
147+
let output = render_repeat_items(&inner, &[], &var_name, root, loop_vars, locator, hydration, config)?;
148+
Ok((output, after))
149+
}
150150
Some(JsonValue::Array(items)) => {
151151
let output = render_repeat_items(&inner, &items, &var_name, root, loop_vars, locator, hydration, config)?;
152152
Ok((output, after))

crates/microsoft-fast-build/tests/errors.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,11 @@ fn test_error_repeat_not_an_array_bool() {
195195
}
196196

197197
#[test]
198-
fn test_error_repeat_missing_state() {
199-
let e = err(r#"<f-repeat value="{{item in missing}}">{{item}}</f-repeat>"#, r#"{}"#);
198+
fn test_error_repeat_not_an_array_null() {
199+
let e = err(r#"<f-repeat value="{{item in items}}">{{item}}</f-repeat>"#, r#"{"items": null}"#);
200200
let msg = e.to_string();
201-
assert!(matches!(e, RenderError::MissingState { .. }), "wrong variant: {msg}");
202-
assert!(msg.contains("missing"), "should name the binding: {msg}");
201+
assert!(matches!(e, RenderError::NotAnArray { .. }), "wrong variant: {msg}");
202+
assert!(msg.contains("items"), "should name the binding: {msg}");
203203
}
204204

205205
// ── JSON parse errors ─────────────────────────────────────────────────────────

crates/microsoft-fast-build/tests/f_repeat.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod common;
22
use common::ok;
3+
use microsoft_fast_build::render_template_without_state;
34

45
#[test]
56
fn test_f_repeat_basic() {
@@ -24,3 +25,30 @@ fn test_f_repeat_root_access() {
2425
"<span>Items: a</span><span>Items: b</span>",
2526
);
2627
}
28+
29+
#[test]
30+
fn test_f_repeat_missing_list_renders_empty() {
31+
assert_eq!(
32+
ok(r#"before<f-repeat value="{{item in items}}"><span>{{item}}</span></f-repeat>after"#, r#"{}"#),
33+
"beforeafter",
34+
);
35+
}
36+
37+
#[test]
38+
fn test_f_repeat_missing_dot_path_list_renders_empty() {
39+
assert_eq!(
40+
ok(r#"<f-repeat value="{{item in items.list}}"><span>{{item}}</span></f-repeat>"#, r#"{}"#),
41+
"",
42+
);
43+
}
44+
45+
#[test]
46+
fn test_f_repeat_without_state_renders_empty() {
47+
let result = render_template_without_state(
48+
r#"<f-repeat value="{{item in items}}"><span>{{item}}</span></f-repeat>"#,
49+
None,
50+
)
51+
.expect("render without state");
52+
53+
assert_eq!(result, "");
54+
}

packages/fast-build/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ When a binding value is not present in state:
8989

9090
- Content bindings render nothing, including unresolved dot paths: `<p>{{foo.bar}}</p>` becomes `<p></p>`.
9191
- Attribute bindings omit the entire attribute, including unresolved dot paths: `<div class="{{foo.bar}}"></div>` becomes `<div></div>`.
92-
- `<f-repeat>` still requires its list binding to resolve to an array and errors when the list is missing or not an array.
92+
- `<f-repeat>` treats a missing list binding, including unresolved dot paths, as an empty array and renders zero iterations. If the binding is present but is not an array, rendering still errors.
9393

9494
### Custom element templates
9595

packages/fast-build/test/config.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,16 @@ describe("no state behavior", () => {
230230
assert.equal(output, "<h1></h1><div></div>");
231231
});
232232

233+
it("treats missing f-repeat lists as empty when state is omitted", () => {
234+
fs.writeFileSync(
235+
path.join(dir, "entry.html"),
236+
'<ul><f-repeat value="{{item in items}}"><li>{{item}}</li></f-repeat></ul>',
237+
);
238+
run(["--entry=entry.html", "--output=out.html"], dir);
239+
const output = fs.readFileSync(path.join(dir, "out.html"), "utf8");
240+
assert.equal(output, "<ul></ul>");
241+
});
242+
233243
it("ignores state.json when state is omitted", () => {
234244
fs.writeFileSync(
235245
path.join(dir, "entry.html"),
@@ -329,6 +339,22 @@ describe("WASM optional state", () => {
329339
);
330340
});
331341

342+
it("render treats missing f-repeat lists as empty arrays", () => {
343+
assert.equal(
344+
WASM.render(
345+
'<f-repeat value="{{item in items}}"><span>{{item}}</span></f-repeat>',
346+
),
347+
"",
348+
);
349+
assert.equal(
350+
WASM.render(
351+
'<f-repeat value="{{item in items.list}}"><span>{{item}}</span></f-repeat>',
352+
"{}",
353+
),
354+
"",
355+
);
356+
});
357+
332358
it("render_with_templates accepts omitted state", () => {
333359
const result = WASM.render_with_templates(
334360
"<test-element></test-element>",

0 commit comments

Comments
 (0)