@barefootjs/client 0.21.0 → 0.21.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.
@@ -640,7 +640,41 @@ function belongsToScope(element, scope) {
640
640
  if (element.getAttribute(BF_SCOPE))
641
641
  return false;
642
642
  const nearestScope = element.closest(`[${BF_SCOPE}]`);
643
- return nearestScope === scope;
643
+ if (nearestScope !== scope)
644
+ return false;
645
+ return !isInsideNestedCommentScope(element, scope);
646
+ }
647
+ function isInsideNestedCommentScope(element, scope) {
648
+ let current = element;
649
+ while (current !== scope) {
650
+ const parent = current.parentNode;
651
+ if (!parent)
652
+ return false;
653
+ if (isTopLevelCommentScopeNode(current))
654
+ return true;
655
+ current = parent;
656
+ }
657
+ return false;
658
+ }
659
+ function isTopLevelCommentScopeNode(node) {
660
+ const parent = node.parentNode;
661
+ if (!parent)
662
+ return false;
663
+ let depth = 0;
664
+ let sib = parent.firstChild;
665
+ while (sib) {
666
+ if (sib === node)
667
+ return depth > 0;
668
+ if (sib.nodeType === Node.COMMENT_NODE) {
669
+ const value = sib.nodeValue ?? "";
670
+ if (value.startsWith(BF_SCOPE_COMMENT_PREFIX))
671
+ depth++;
672
+ else if (value.startsWith(BF_SCOPE_COMMENT_END_PREFIX) && depth > 0)
673
+ depth--;
674
+ }
675
+ sib = sib.nextSibling;
676
+ }
677
+ return false;
644
678
  }
645
679
  function isInCommentScopeRange(element, commentNode) {
646
680
  const boundary = getCommentScopeBoundary(commentNode);
@@ -665,7 +699,8 @@ function find(scope, selector, ignoreScope) {
665
699
  if (commentInfo) {
666
700
  if (candidate.parentElement === commentInfo.commentNode.parentElement)
667
701
  return candidate;
668
- if (!candidate.closest(`[${BF_SCOPE}]`))
702
+ const nearestScope = candidate.closest(`[${BF_SCOPE}]`);
703
+ if (!nearestScope || !isInCommentScopeRange(nearestScope, commentInfo.commentNode))
669
704
  return candidate;
670
705
  } else {
671
706
  if (belongsToScope(candidate, scope))
@@ -677,6 +712,20 @@ function find(scope, selector, ignoreScope) {
677
712
  return findInPortals(scopeId, selector);
678
713
  return null;
679
714
  }
715
+ function findCondTarget(scope, selector) {
716
+ const commentInfo = commentScopeRegistry.get(scope);
717
+ if (!commentInfo) {
718
+ return scope.querySelector(selector);
719
+ }
720
+ for (const candidate of candidatesInScope(scope, selector)) {
721
+ if (candidate.parentElement === commentInfo.commentNode.parentElement)
722
+ return candidate;
723
+ const nearestScope = candidate.closest(`[${BF_SCOPE}]`);
724
+ if (!nearestScope || !isInCommentScopeRange(nearestScope, commentInfo.commentNode))
725
+ return candidate;
726
+ }
727
+ return null;
728
+ }
680
729
  function findInPortals(scopeId, selector) {
681
730
  const portals = document.querySelectorAll(`[${BF_PORTAL_OWNER}="${scopeId}"]`);
682
731
  for (const portal of portals) {
@@ -2496,7 +2545,7 @@ function insert(scope, id, conditionFn, whenTrue, whenFalse, bfId) {
2496
2545
  }
2497
2546
  function autoFocusConditionalElement(region, id) {
2498
2547
  requestAnimationFrame(() => {
2499
- const condEl = region.anchor ? findCondElInRange(region.anchor, id) : region.bindScope.querySelector(`[${BF_COND}="${id}"]`);
2548
+ const condEl = region.anchor ? findCondElInRange(region.anchor, id) : findCondTarget(region.bindScope, `[${BF_COND}="${id}"]`);
2500
2549
  if (condEl) {
2501
2550
  const autofocusEl = condEl.matches("[autofocus]") ? condEl : condEl.querySelector("[autofocus]");
2502
2551
  if (autofocusEl && typeof autofocusEl.focus === "function") {
@@ -2537,15 +2586,14 @@ function updateFragmentConditional(region, id, result) {
2537
2586
  if (region.anchor) {
2538
2587
  startComment = findCondStartInRange(region.anchor, id);
2539
2588
  } else {
2540
- const walker = document.createTreeWalker(scope, NodeFilter.SHOW_COMMENT);
2541
- while (walker.nextNode()) {
2542
- if (walker.currentNode.nodeValue === startMarker) {
2543
- startComment = walker.currentNode;
2589
+ for (const comment of commentsInScope(scope)) {
2590
+ if (comment.nodeValue === startMarker) {
2591
+ startComment = comment;
2544
2592
  break;
2545
2593
  }
2546
2594
  }
2547
2595
  }
2548
- const condEl = region.anchor ? findCondElInRange(region.anchor, id) : scope.querySelector(`[${BF_COND}="${id}"]`);
2596
+ const condEl = region.anchor ? findCondElInRange(region.anchor, id) : findCondTarget(scope, `[${BF_COND}="${id}"]`);
2549
2597
  const endMarker = `bf-cond-end:${id}`;
2550
2598
  if (startComment) {
2551
2599
  const nodesToRemove = [];
@@ -2585,7 +2633,7 @@ function updateFragmentConditional(region, id, result) {
2585
2633
  }
2586
2634
  }
2587
2635
  function updateElementConditional(region, id, result) {
2588
- const condEl = region.anchor ? findCondElInRange(region.anchor, id) : region.bindScope.querySelector(`[${BF_COND}="${id}"]`);
2636
+ const condEl = region.anchor ? findCondElInRange(region.anchor, id) : findCondTarget(region.bindScope, `[${BF_COND}="${id}"]`);
2589
2637
  if (!condEl)
2590
2638
  return;
2591
2639
  const { html, slots } = result;
@@ -30,6 +30,32 @@ export declare function findScope(name: string, idx: number, parent: Element | D
30
30
  * @returns The matching element or null
31
31
  */
32
32
  export declare function find(scope: Element | null, selector: string, ignoreScope?: boolean): Element | null;
33
+ /**
34
+ * Find a conditional's own target (a `bf-c="id"` element, for `insert.ts`)
35
+ * within `scope`'s content range.
36
+ *
37
+ * Deliberately narrower than `find()`: for a regular (non-comment) scope,
38
+ * this is a **plain, unfiltered** `scope.querySelector(selector)` — not
39
+ * `find()`'s `belongsToScope`-gated search. `belongsToScope` accepts a
40
+ * candidate only if `candidate.closest('[bf-s]')` is *exactly* `scope` —
41
+ * which can never hold when `scope` itself carries no `bf-s`. That's the
42
+ * common case for `insert()`'s `region.bindScope`: a `mapArray` loop item's
43
+ * cloned template root (e.g. a `.comment-item` `<div>`) is keyed by
44
+ * `data-key` for reconciliation, not given its own `bf-s`, so any `bf-c`
45
+ * conditional inside that item would be rejected outright by
46
+ * `belongsToScope` — not because of any nested-child-scope subtlety, just
47
+ * because `scope` itself isn't `bf-s`-addressable. Using `find()` here
48
+ * previously broke exactly that shape (piconic-ai/barefootjs#2313's first
49
+ * attempt, caught by the `site/ui` e2e suite's `SocialThreadDemo`
50
+ * comment-editing test — a per-comment `editing` conditional inside a
51
+ * `sortedComments().map(...)` loop item with no `bf-s` of its own).
52
+ *
53
+ * A comment-scope proxy (fragment-root component) still gets the
54
+ * comment-range-bounded, nested-fragment-excluding search — the same rule
55
+ * `find()`'s comment branch uses — since that IS what's needed to walk past
56
+ * a fragment-root's own top-level siblings correctly (#2312).
57
+ */
58
+ export declare function findCondTarget(scope: Element, selector: string): Element | null;
33
59
  /**
34
60
  * Find an element matching a selector, checking the element itself first,
35
61
  * then its descendants. Unlike querySelector() which only searches descendants,
@@ -98,6 +124,22 @@ export declare function $c(scope: Element | null, ...ids: string[]): (Element |
98
124
  * walker-owned root fragments.
99
125
  */
100
126
  export declare function findCommentChildScope(scope: Element, parentIds: readonly string[], slotId: string): Element | null;
127
+ /**
128
+ * Enumerate comment nodes in a scope's DOM range: the comment-scope
129
+ * sibling range for comment-anchored scopes, the element subtree
130
+ * otherwise. Mirrors candidatesInScope's notion of "where the scope's
131
+ * content lives", but yields comments instead of elements.
132
+ *
133
+ * Exported for `insert.ts`'s branch-swap path (`updateFragmentConditional`),
134
+ * which otherwise walked `scope`'s own descendants with a bare
135
+ * `document.createTreeWalker` — never finding a conditional's
136
+ * `bf-cond-start:`/`bf-cond-end:` markers when they sit as a *sibling* of
137
+ * the scope's comment-scope proxy rather than nested inside it (true for
138
+ * any fragment-root component's own top-level conditional, since the proxy
139
+ * is one specific top-level element and the conditional's markers may be
140
+ * others). See piconic-ai/sora's `ListSidebar` for the real-world repro.
141
+ */
142
+ export declare function commentsInScope(scope: Element): Generator<Comment>;
101
143
  /**
102
144
  * Find Text nodes for reactive text expressions marked by comment nodes.
103
145
  * Expects marker format: <!--bf:sX-->text<!--/-->
@@ -1 +1 @@
1
- {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/runtime/query.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,sFAAsF;AACtF,eAAO,MAAM,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAGJ,CAAA;AAqCnC;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,EACjC,OAAO,CAAC,EAAE,OAAO,GAChB,OAAO,GAAG,IAAI,CAoDhB;AA6HD;;;;;;;;;;GAUG;AACH,wBAAgB,IAAI,CAClB,KAAK,EAAE,OAAO,GAAG,IAAI,EACrB,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,OAAO,GACpB,OAAO,GAAG,IAAI,CA2BhB;AAuBD;;;;;;;GAOG;AACH,wBAAgB,GAAG,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CA0BxE;AA+CD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAU9E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAO1E;AAED;;;;;;;;GAQG;AACH,wBAAgB,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAO7E;AAED;;;;;GAKG;AACH,wBAAgB,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAE9E;AAkDD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,MAAM,EAAE,MAAM,GACb,OAAO,GAAG,IAAI,CAehB;AAmFD;;;;;;;GAOG;AACH,wBAAgB,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAkC3E;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAS3D;AAED,OAAO,EAAE,oBAAoB,IAAI,MAAM,EAAE,CAAA"}
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/runtime/query.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,sFAAsF;AACtF,eAAO,MAAM,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAGJ,CAAA;AAqCnC;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,EACjC,OAAO,CAAC,EAAE,OAAO,GAChB,OAAO,GAAG,IAAI,CAoDhB;AAiLD;;;;;;;;;;GAUG;AACH,wBAAgB,IAAI,CAClB,KAAK,EAAE,OAAO,GAAG,IAAI,EACrB,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,OAAO,GACpB,OAAO,GAAG,IAAI,CAiChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAW/E;AAuBD;;;;;;;GAOG;AACH,wBAAgB,GAAG,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CA0BxE;AA+CD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAU9E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAO1E;AAED;;;;;;;;GAQG;AACH,wBAAgB,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAO7E;AAED;;;;;GAKG;AACH,wBAAgB,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAE9E;AAkDD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,MAAM,EAAE,MAAM,GACb,OAAO,GAAG,IAAI,CAehB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAiB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC,OAAO,CAAC,CAkBnE;AAyDD;;;;;;;GAOG;AACH,wBAAgB,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAkC3E;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAS3D;AAED,OAAO,EAAE,oBAAoB,IAAI,MAAM,EAAE,CAAA"}
@@ -1048,7 +1048,41 @@ function belongsToScope(element, scope) {
1048
1048
  if (element.getAttribute(BF_SCOPE))
1049
1049
  return false;
1050
1050
  const nearestScope = element.closest(`[${BF_SCOPE}]`);
1051
- return nearestScope === scope;
1051
+ if (nearestScope !== scope)
1052
+ return false;
1053
+ return !isInsideNestedCommentScope(element, scope);
1054
+ }
1055
+ function isInsideNestedCommentScope(element, scope) {
1056
+ let current = element;
1057
+ while (current !== scope) {
1058
+ const parent = current.parentNode;
1059
+ if (!parent)
1060
+ return false;
1061
+ if (isTopLevelCommentScopeNode(current))
1062
+ return true;
1063
+ current = parent;
1064
+ }
1065
+ return false;
1066
+ }
1067
+ function isTopLevelCommentScopeNode(node) {
1068
+ const parent = node.parentNode;
1069
+ if (!parent)
1070
+ return false;
1071
+ let depth = 0;
1072
+ let sib = parent.firstChild;
1073
+ while (sib) {
1074
+ if (sib === node)
1075
+ return depth > 0;
1076
+ if (sib.nodeType === Node.COMMENT_NODE) {
1077
+ const value = sib.nodeValue ?? "";
1078
+ if (value.startsWith(BF_SCOPE_COMMENT_PREFIX))
1079
+ depth++;
1080
+ else if (value.startsWith(BF_SCOPE_COMMENT_END_PREFIX) && depth > 0)
1081
+ depth--;
1082
+ }
1083
+ sib = sib.nextSibling;
1084
+ }
1085
+ return false;
1052
1086
  }
1053
1087
  function isInCommentScopeRange(element, commentNode) {
1054
1088
  const boundary = getCommentScopeBoundary(commentNode);
@@ -1073,7 +1107,8 @@ function find(scope, selector, ignoreScope) {
1073
1107
  if (commentInfo) {
1074
1108
  if (candidate.parentElement === commentInfo.commentNode.parentElement)
1075
1109
  return candidate;
1076
- if (!candidate.closest(`[${BF_SCOPE}]`))
1110
+ const nearestScope = candidate.closest(`[${BF_SCOPE}]`);
1111
+ if (!nearestScope || !isInCommentScopeRange(nearestScope, commentInfo.commentNode))
1077
1112
  return candidate;
1078
1113
  } else {
1079
1114
  if (belongsToScope(candidate, scope))
@@ -1085,6 +1120,20 @@ function find(scope, selector, ignoreScope) {
1085
1120
  return findInPortals(scopeId, selector);
1086
1121
  return null;
1087
1122
  }
1123
+ function findCondTarget(scope, selector) {
1124
+ const commentInfo = commentScopeRegistry.get(scope);
1125
+ if (!commentInfo) {
1126
+ return scope.querySelector(selector);
1127
+ }
1128
+ for (const candidate of candidatesInScope(scope, selector)) {
1129
+ if (candidate.parentElement === commentInfo.commentNode.parentElement)
1130
+ return candidate;
1131
+ const nearestScope = candidate.closest(`[${BF_SCOPE}]`);
1132
+ if (!nearestScope || !isInCommentScopeRange(nearestScope, commentInfo.commentNode))
1133
+ return candidate;
1134
+ }
1135
+ return null;
1136
+ }
1088
1137
  function findInPortals(scopeId, selector) {
1089
1138
  const portals = document.querySelectorAll(`[${BF_PORTAL_OWNER}="${scopeId}"]`);
1090
1139
  for (const portal of portals) {
@@ -2897,7 +2946,7 @@ function insert(scope, id, conditionFn, whenTrue, whenFalse, bfId) {
2897
2946
  }
2898
2947
  function autoFocusConditionalElement(region, id) {
2899
2948
  requestAnimationFrame(() => {
2900
- const condEl = region.anchor ? findCondElInRange(region.anchor, id) : region.bindScope.querySelector(`[${BF_COND}="${id}"]`);
2949
+ const condEl = region.anchor ? findCondElInRange(region.anchor, id) : findCondTarget(region.bindScope, `[${BF_COND}="${id}"]`);
2901
2950
  if (condEl) {
2902
2951
  const autofocusEl = condEl.matches("[autofocus]") ? condEl : condEl.querySelector("[autofocus]");
2903
2952
  if (autofocusEl && typeof autofocusEl.focus === "function") {
@@ -2938,15 +2987,14 @@ function updateFragmentConditional(region, id, result) {
2938
2987
  if (region.anchor) {
2939
2988
  startComment = findCondStartInRange(region.anchor, id);
2940
2989
  } else {
2941
- const walker = document.createTreeWalker(scope, NodeFilter.SHOW_COMMENT);
2942
- while (walker.nextNode()) {
2943
- if (walker.currentNode.nodeValue === startMarker) {
2944
- startComment = walker.currentNode;
2990
+ for (const comment of commentsInScope(scope)) {
2991
+ if (comment.nodeValue === startMarker) {
2992
+ startComment = comment;
2945
2993
  break;
2946
2994
  }
2947
2995
  }
2948
2996
  }
2949
- const condEl = region.anchor ? findCondElInRange(region.anchor, id) : scope.querySelector(`[${BF_COND}="${id}"]`);
2997
+ const condEl = region.anchor ? findCondElInRange(region.anchor, id) : findCondTarget(scope, `[${BF_COND}="${id}"]`);
2950
2998
  const endMarker = `bf-cond-end:${id}`;
2951
2999
  if (startComment) {
2952
3000
  const nodesToRemove = [];
@@ -2986,7 +3034,7 @@ function updateFragmentConditional(region, id, result) {
2986
3034
  }
2987
3035
  }
2988
3036
  function updateElementConditional(region, id, result) {
2989
- const condEl = region.anchor ? findCondElInRange(region.anchor, id) : region.bindScope.querySelector(`[${BF_COND}="${id}"]`);
3037
+ const condEl = region.anchor ? findCondElInRange(region.anchor, id) : findCondTarget(region.bindScope, `[${BF_COND}="${id}"]`);
2990
3038
  if (!condEl)
2991
3039
  return;
2992
3040
  const { html, slots } = result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/client",
3
- "version": "0.21.0",
3
+ "version": "0.21.3",
4
4
  "description": "BarefootJS client package: reactive primitives (SSR-safe) plus browser runtime under the `/runtime` subpath (compiler target)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "directory": "packages/client"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.21.0"
58
+ "@barefootjs/shared": "0.21.3"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { createEffect, untrack } from '@barefootjs/client/reactive'
10
- import { find } from './query.ts'
10
+ import { find, findCondTarget, commentsInScope } from './query.ts'
11
11
  import { setParentScopeId, parseHTML } from './component.ts'
12
12
  import { commentScopeRegistry, getCommentScopeBoundary } from './scope.ts'
13
13
  import { BF_COND, BF_SCOPE, BF_LOOP_ITEM } from '@barefootjs/shared'
@@ -342,7 +342,7 @@ function autoFocusConditionalElement(region: CondRegion, id: string): void {
342
342
  requestAnimationFrame(() => {
343
343
  const condEl = region.anchor
344
344
  ? findCondElInRange(region.anchor, id)
345
- : region.bindScope.querySelector(`[${BF_COND}="${id}"]`)
345
+ : findCondTarget(region.bindScope, `[${BF_COND}="${id}"]`)
346
346
  if (condEl) {
347
347
  const autofocusEl = condEl.matches('[autofocus]')
348
348
  ? condEl
@@ -406,10 +406,15 @@ function updateFragmentConditional(region: CondRegion, id: string, result: Branc
406
406
  if (region.anchor) {
407
407
  startComment = findCondStartInRange(region.anchor, id)
408
408
  } else {
409
- const walker = document.createTreeWalker(scope, NodeFilter.SHOW_COMMENT)
410
- while (walker.nextNode()) {
411
- if (walker.currentNode.nodeValue === startMarker) {
412
- startComment = walker.currentNode as Comment
409
+ // commentsInScope is comment-scope-aware: for a fragment-root
410
+ // component's scope (a comment-scope proxy element), it walks the
411
+ // proxy's *sibling* range rather than only `scope`'s own descendants —
412
+ // required when this conditional's markers are themselves a top-level
413
+ // sibling of the proxy, not nested inside it (a bare TreeWalker(scope)
414
+ // never finds them, silently freezing the branch after first render).
415
+ for (const comment of commentsInScope(scope)) {
416
+ if (comment.nodeValue === startMarker) {
417
+ startComment = comment
413
418
  break
414
419
  }
415
420
  }
@@ -417,7 +422,7 @@ function updateFragmentConditional(region: CondRegion, id: string, result: Branc
417
422
 
418
423
  const condEl = region.anchor
419
424
  ? findCondElInRange(region.anchor, id)
420
- : scope.querySelector(`[${BF_COND}="${id}"]`)
425
+ : findCondTarget(scope, `[${BF_COND}="${id}"]`)
421
426
 
422
427
  const endMarker = `bf-cond-end:${id}`
423
428
 
@@ -486,9 +491,15 @@ function updateFragmentConditional(region: CondRegion, id: string, result: Branc
486
491
  * Update element conditional (single element with bf-c)
487
492
  */
488
493
  function updateElementConditional(region: CondRegion, id: string, result: BranchTemplateResult): void {
494
+ // findCondTarget, not find(): for a comment-scope proxy it resolves a
495
+ // top-level sibling conditional the same way first hydration does, but
496
+ // for a regular scope it's a plain scope.querySelector(...) — not
497
+ // find()'s belongsToScope-gated search, which requires scope itself to
498
+ // carry bf-s (a mapArray loop item's cloned root usually doesn't; see
499
+ // findCondTarget's doc comment in query.ts).
489
500
  const condEl = region.anchor
490
501
  ? findCondElInRange(region.anchor, id)
491
- : region.bindScope.querySelector(`[${BF_COND}="${id}"]`)
502
+ : findCondTarget(region.bindScope, `[${BF_COND}="${id}"]`)
492
503
  if (!condEl) return
493
504
 
494
505
  const { html, slots } = result
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { commentScopeRegistry, getCommentScopeBoundary } from './scope.ts'
10
10
  import { hydratedScopes } from './hydration-state.ts'
11
- import { BF_SCOPE, BF_SLOT, BF_PORTAL_OWNER, BF_PARENT_OWNED_PREFIX, BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
11
+ import { BF_SCOPE, BF_SLOT, BF_PORTAL_OWNER, BF_PARENT_OWNED_PREFIX, BF_SCOPE_COMMENT_PREFIX, BF_SCOPE_COMMENT_END_PREFIX } from '@barefootjs/shared'
12
12
 
13
13
  /** CSS attribute-value escape with a fallback for environments lacking CSS.escape. */
14
14
  export const cssEscape: (s: string) => string =
@@ -222,7 +222,59 @@ function belongsToScope(element: Element, scope: Element): boolean {
222
222
 
223
223
  // Check if nearest scope matches
224
224
  const nearestScope = element.closest(`[${BF_SCOPE}]`)
225
- return nearestScope === scope
225
+ if (nearestScope !== scope) return false
226
+
227
+ // A fragment-rooted child mounted between `element` and `scope` has no
228
+ // `bf-s` element of its own (#2302) — `closest()` walks straight past it
229
+ // to `scope`, so a slot search on `scope` can wrongly claim a descendant
230
+ // that actually belongs to that nested child's comment scope. Reject it.
231
+ return !isInsideNestedCommentScope(element, scope)
232
+ }
233
+
234
+ /**
235
+ * True if some ancestor of `element` (strictly below `scope`) is a
236
+ * top-level node of a fragment-rooted child's `<!--bf-scope:...-->` range —
237
+ * i.e. `element` structurally belongs to a nested child component even
238
+ * though that child has no `bf-s`-attributed element for `.closest()` to
239
+ * stop at, and regardless of whether `commentScopeRegistry` has registered
240
+ * it yet (a parent's own slot lookups can run before its child-scope
241
+ * lookups register the child, #2302).
242
+ */
243
+ function isInsideNestedCommentScope(element: Element, scope: Element): boolean {
244
+ let current: Node = element
245
+ while (current !== scope) {
246
+ const parent: Node | null = current.parentNode
247
+ if (!parent) return false
248
+ if (isTopLevelCommentScopeNode(current)) return true
249
+ current = parent
250
+ }
251
+ return false
252
+ }
253
+
254
+ /**
255
+ * True if `node` falls inside a `<!--bf-scope:...-->` … `<!--bf-/scope:...-->`
256
+ * range among its own siblings — i.e. `node` is (or is inside) a top-level
257
+ * element of a fragment-rooted child mounted as `node`'s sibling. Single
258
+ * left-to-right pass over the sibling list with a nesting depth counter, so
259
+ * a nested child's own begin/end pair doesn't prematurely close an outer
260
+ * one. Missing end markers (older SSR output) leave depth un-decremented,
261
+ * matching `getCommentScopeBoundary`'s legacy end-of-parent fallback.
262
+ */
263
+ function isTopLevelCommentScopeNode(node: Node): boolean {
264
+ const parent = node.parentNode
265
+ if (!parent) return false
266
+ let depth = 0
267
+ let sib: Node | null = parent.firstChild
268
+ while (sib) {
269
+ if (sib === node) return depth > 0
270
+ if (sib.nodeType === Node.COMMENT_NODE) {
271
+ const value = (sib as Comment).nodeValue ?? ''
272
+ if (value.startsWith(BF_SCOPE_COMMENT_PREFIX)) depth++
273
+ else if (value.startsWith(BF_SCOPE_COMMENT_END_PREFIX) && depth > 0) depth--
274
+ }
275
+ sib = sib.nextSibling
276
+ }
277
+ return false
226
278
  }
227
279
 
228
280
  /**
@@ -271,10 +323,16 @@ export function find(
271
323
  if (ignoreScope) return candidate
272
324
  if (commentInfo) {
273
325
  // Comment scope: top-level siblings in the comment range are always
274
- // accepted (even if they have bf-s, like proxy elements). Descendants
275
- // are accepted only if not inside a nested bf-s scope.
326
+ // accepted (even if they have bf-s, like proxy elements). A descendant
327
+ // nested inside one of those siblings is accepted unless a *nested*
328
+ // child scope sits between it and this one — i.e. its nearest bf-s
329
+ // ancestor falls within this comment's own sibling range. An ancestor
330
+ // bf-s element outside that range doesn't disqualify the candidate:
331
+ // a fragment-root child mounted inside a normal parent island always
332
+ // has one, and `.closest()` alone can't tell the two apart (#2302).
276
333
  if (candidate.parentElement === commentInfo.commentNode.parentElement) return candidate
277
- if (!candidate.closest(`[${BF_SCOPE}]`)) return candidate
334
+ const nearestScope = candidate.closest(`[${BF_SCOPE}]`)
335
+ if (!nearestScope || !isInCommentScopeRange(nearestScope, commentInfo.commentNode)) return candidate
278
336
  } else {
279
337
  if (belongsToScope(candidate, scope)) return candidate
280
338
  }
@@ -287,6 +345,44 @@ export function find(
287
345
  return null
288
346
  }
289
347
 
348
+ /**
349
+ * Find a conditional's own target (a `bf-c="id"` element, for `insert.ts`)
350
+ * within `scope`'s content range.
351
+ *
352
+ * Deliberately narrower than `find()`: for a regular (non-comment) scope,
353
+ * this is a **plain, unfiltered** `scope.querySelector(selector)` — not
354
+ * `find()`'s `belongsToScope`-gated search. `belongsToScope` accepts a
355
+ * candidate only if `candidate.closest('[bf-s]')` is *exactly* `scope` —
356
+ * which can never hold when `scope` itself carries no `bf-s`. That's the
357
+ * common case for `insert()`'s `region.bindScope`: a `mapArray` loop item's
358
+ * cloned template root (e.g. a `.comment-item` `<div>`) is keyed by
359
+ * `data-key` for reconciliation, not given its own `bf-s`, so any `bf-c`
360
+ * conditional inside that item would be rejected outright by
361
+ * `belongsToScope` — not because of any nested-child-scope subtlety, just
362
+ * because `scope` itself isn't `bf-s`-addressable. Using `find()` here
363
+ * previously broke exactly that shape (piconic-ai/barefootjs#2313's first
364
+ * attempt, caught by the `site/ui` e2e suite's `SocialThreadDemo`
365
+ * comment-editing test — a per-comment `editing` conditional inside a
366
+ * `sortedComments().map(...)` loop item with no `bf-s` of its own).
367
+ *
368
+ * A comment-scope proxy (fragment-root component) still gets the
369
+ * comment-range-bounded, nested-fragment-excluding search — the same rule
370
+ * `find()`'s comment branch uses — since that IS what's needed to walk past
371
+ * a fragment-root's own top-level siblings correctly (#2312).
372
+ */
373
+ export function findCondTarget(scope: Element, selector: string): Element | null {
374
+ const commentInfo = commentScopeRegistry.get(scope)
375
+ if (!commentInfo) {
376
+ return scope.querySelector(selector)
377
+ }
378
+ for (const candidate of candidatesInScope(scope, selector)) {
379
+ if (candidate.parentElement === commentInfo.commentNode.parentElement) return candidate
380
+ const nearestScope = candidate.closest(`[${BF_SCOPE}]`)
381
+ if (!nearestScope || !isInCommentScopeRange(nearestScope, commentInfo.commentNode)) return candidate
382
+ }
383
+ return null
384
+ }
385
+
290
386
  /**
291
387
  * Search in portals owned by a scope.
292
388
  */
@@ -551,8 +647,17 @@ export function findCommentChildScope(
551
647
  * sibling range for comment-anchored scopes, the element subtree
552
648
  * otherwise. Mirrors candidatesInScope's notion of "where the scope's
553
649
  * content lives", but yields comments instead of elements.
650
+ *
651
+ * Exported for `insert.ts`'s branch-swap path (`updateFragmentConditional`),
652
+ * which otherwise walked `scope`'s own descendants with a bare
653
+ * `document.createTreeWalker` — never finding a conditional's
654
+ * `bf-cond-start:`/`bf-cond-end:` markers when they sit as a *sibling* of
655
+ * the scope's comment-scope proxy rather than nested inside it (true for
656
+ * any fragment-root component's own top-level conditional, since the proxy
657
+ * is one specific top-level element and the conditional's markers may be
658
+ * others). See piconic-ai/sora's `ListSidebar` for the real-world repro.
554
659
  */
555
- function* commentsInScope(scope: Element): Generator<Comment> {
660
+ export function* commentsInScope(scope: Element): Generator<Comment> {
556
661
  const commentInfo = commentScopeRegistry.get(scope)
557
662
 
558
663
  if (commentInfo) {