Skip to content

Commit cd0a21b

Browse files
janechuCopilot
andcommitted
fix: scope repeat hydration scan and add test cases
- Bound repeat hydration scan to this directive's content binding boundaries (via bindingViewBoundaries) to prevent scanning into sibling repeat blocks - Fix comment on empty-item handling to match actual implementation full diagnostic property bag - Add Rust tests: single-item repeat, many-item repeat (count-based assertions), and multiple attribute bindings on one element Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2046dec commit cd0a21b

2 files changed

Lines changed: 116 additions & 13 deletions

File tree

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,92 @@ fn test_hydration_f_repeat_empty() {
383383
);
384384
}
385385

386+
/// f-repeat with a single item: exactly one start/end pair.
387+
#[test]
388+
fn test_hydration_f_repeat_single_item() {
389+
let locator = make_locator(&[(
390+
"test-element",
391+
"<ul><f-repeat value=\"{{item in list}}\"><li>{{item}}</li></f-repeat></ul>",
392+
)]);
393+
let root = hand_root(vec![(
394+
"list",
395+
arr_val(vec![str_val("Only")]),
396+
)]);
397+
let result = render_with_locator(
398+
r#"<test-element list="{{list}}"></test-element>"#,
399+
&root,
400+
&locator,
401+
None,
402+
).unwrap();
403+
404+
assert_eq!(
405+
result.matches("<!--fe:r-->").count(),
406+
1,
407+
"expected one item start marker: {result}"
408+
);
409+
assert_eq!(
410+
result.matches("<!--fe:/r-->").count(),
411+
1,
412+
"expected one item end marker: {result}"
413+
);
414+
assert!(result.contains("<!--fe:b-->Only<!--fe:/b-->"),
415+
"item binding: {result}");
416+
}
417+
418+
/// f-repeat with many items: marker counts match item count.
419+
#[test]
420+
fn test_hydration_f_repeat_many_items() {
421+
let locator = make_locator(&[(
422+
"test-element",
423+
"<f-repeat value=\"{{item in list}}\"><span>{{item}}</span></f-repeat>",
424+
)]);
425+
let items: Vec<_> = (0..5).map(|i| str_val(&format!("Item{i}"))).collect();
426+
let root = hand_root(vec![("list", arr_val(items))]);
427+
let result = render_with_locator(
428+
r#"<test-element list="{{list}}"></test-element>"#,
429+
&root,
430+
&locator,
431+
None,
432+
).unwrap();
433+
434+
assert_eq!(
435+
result.matches("<!--fe:r-->").count(),
436+
5,
437+
"expected five item start markers: {result}"
438+
);
439+
assert_eq!(
440+
result.matches("<!--fe:/r-->").count(),
441+
5,
442+
"expected five item end markers: {result}"
443+
);
444+
for i in 0..5 {
445+
assert!(result.contains(&format!("<!--fe:b-->Item{i}<!--fe:/b-->")),
446+
"item {i} binding: {result}");
447+
}
448+
}
449+
450+
/// Multiple attribute bindings on one element: data-fe count reflects total.
451+
#[test]
452+
fn test_hydration_multiple_attr_bindings() {
453+
let locator = make_locator(&[(
454+
"test-element",
455+
r#"<div class="{{cls}}" id="{{id}}" title="{{tip}}">text</div>"#,
456+
)]);
457+
let root = hand_root(vec![
458+
("cls", str_val("foo")),
459+
("id", str_val("bar")),
460+
("tip", str_val("baz")),
461+
]);
462+
let result = render_with_locator(
463+
r#"<test-element cls="{{cls}}" id="{{id}}" tip="{{tip}}"></test-element>"#,
464+
&root,
465+
&locator,
466+
None,
467+
).unwrap();
468+
469+
assert!(result.contains(r#"data-fe="3""#), "three bindings: {result}");
470+
}
471+
386472
// ── f-ref / f-slotted (single-brace directive attrs) ─────────────────────────
387473

388474
/// `f-ref="{video}"` — single-brace attribute directive gets a compact marker

packages/fast-element/src/templating/repeat.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -412,10 +412,18 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
412412
serializer ?? (serializer = new XMLSerializer());
413413
this.views = new Array(itemCount);
414414

415+
// Determine the scan boundary for this repeat directive.
416+
// Use the content binding boundaries (from the parent's fe:b/fe:/b
417+
// markers) to avoid scanning into sibling repeat blocks.
418+
const boundaries =
419+
isHydratable(this.controller) &&
420+
this.controller.bindingViewBoundaries[this.directive.targetNodeId];
421+
const scanStop: Node | null = boundaries ? boundaries.first : null;
422+
415423
let current = this.location.previousSibling;
416424
let itemIndex = itemCount - 1; // items render in order; walk backward
417425

418-
while (current !== null && itemIndex >= 0) {
426+
while (current !== null && current !== scanStop && itemIndex >= 0) {
419427
if (!isCommentNode(current) || current.data !== "fe:/r") {
420428
current = current.previousSibling;
421429
continue;
@@ -425,17 +433,26 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
425433
current.data = "";
426434
const end = current.previousSibling;
427435
if (!end) {
428-
throw new Error(
436+
throw new HydrationRepeatError(
429437
`Error when hydrating inside "${
430438
(this.location.getRootNode() as ShadowRoot).host.nodeName
431439
}": end should never be null.`,
440+
{
441+
index: itemIndex,
442+
hydrationStage: "hydrateViews",
443+
itemsLength: itemCount,
444+
viewsState: this.views.map(v => (v ? "hydrated" : "empty")),
445+
rootNodeContent: getSerializer().serializeToString(
446+
this.location.getRootNode() as any,
447+
),
448+
},
432449
);
433450
}
434451

435452
// Find matching start marker via balanced counting
436453
let start: Node | null = end;
437454
let depth = 0;
438-
while (start !== null) {
455+
while (start !== null && start !== scanStop) {
439456
if (isCommentNode(start)) {
440457
if (start.data === "fe:/r") {
441458
depth++;
@@ -446,12 +463,11 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
446463
current = startMarker.previousSibling;
447464
const itemStart = startMarker.nextSibling!;
448465

449-
// When the item is empty (start and end markers
450-
// are adjacent), itemStart IS the cleared end
451-
// marker and end IS the cleared start marker —
452-
// an inverted range. Detect this by checking if
453-
// end precedes itemStart in sibling order, and
454-
// use itemStart as both first and last.
466+
// Empty item: start and end markers are adjacent
467+
// with no content between them. end === startMarker
468+
// because previousSibling of the end marker IS the
469+
// start marker. Use the cleared end marker comment
470+
// as both first and last for a valid single-node range.
455471
const itemEnd = end === startMarker ? itemStart : end;
456472

457473
const view = template.hydrate(itemStart, itemEnd);
@@ -465,7 +481,7 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
465481
}
466482
start = start.previousSibling;
467483
}
468-
if (!start) {
484+
if (!start || start === scanStop) {
469485
throw new HydrationRepeatError(
470486
`Error when hydrating inside "${
471487
(this.location.getRootNode() as ShadowRoot).host.nodeName
@@ -500,10 +516,11 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
500516
);
501517
}
502518

503-
// Overflow check: detect extra repeat markers beyond items.length
504-
if (current !== null) {
519+
// Overflow check: detect extra repeat markers beyond items.length,
520+
// bounded to this directive's scan range.
521+
if (current !== null && current !== scanStop) {
505522
let remaining: Node | null = current;
506-
while (remaining !== null) {
523+
while (remaining !== null && remaining !== scanStop) {
507524
if (isCommentNode(remaining) && remaining.data === "fe:/r") {
508525
throw new HydrationRepeatError(
509526
`Error when hydrating inside "${

0 commit comments

Comments
 (0)