@barefootjs/client 0.5.0 → 0.5.2

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.
@@ -9,7 +9,83 @@
9
9
  import { createEffect, untrack } from '@barefootjs/client/reactive'
10
10
  import { find } from './query'
11
11
  import { setParentScopeId, parseHTML } from './component'
12
- import { BF_COND, BF_SCOPE } from '@barefootjs/shared'
12
+ import { commentScopeRegistry, getCommentScopeBoundary } from './scope'
13
+ import { BF_COND, BF_SCOPE, BF_LOOP_ITEM } from '@barefootjs/shared'
14
+
15
+ /**
16
+ * Resolved search context for an `insert()` call (#1665).
17
+ *
18
+ * `anchor === null` is the legacy element-scope path: every DOM read goes
19
+ * through the component scope element exactly as before. When `insert()` is
20
+ * given a `<!--bf-loop-i:<key>-->` anchor instead, the conditional is a
21
+ * whole loop item with no wrapper element, so all reads are confined to that
22
+ * item's sibling range — letting every item reuse the same conditional slot
23
+ * id without colliding.
24
+ */
25
+ interface CondRegion {
26
+ /** The loop-item anchor comment, or `null` for element scopes. */
27
+ anchor: Comment | null
28
+ /** Element handed to `find()`/`$`/`$t`/`bindEvents`. For loop items this
29
+ * is a detached proxy registered in `commentScopeRegistry`, so the shared
30
+ * query machinery walks the item's range via the comment-scope branch. */
31
+ bindScope: Element
32
+ /** Parent component scope id for `setParentScopeId()` / `renderChild`. */
33
+ parentScopeId: string | null
34
+ }
35
+
36
+ function makeRegion(scope: Element | Comment): CondRegion {
37
+ if (scope.nodeType === Node.COMMENT_NODE) {
38
+ const anchor = scope as Comment
39
+ const parentEl = anchor.parentElement
40
+ const componentScope = parentEl?.closest(`[${BF_SCOPE}]`) ?? null
41
+ const parentScopeId = componentScope?.getAttribute(BF_SCOPE) ?? null
42
+ // Detached proxy: candidatesInScope() keys off the registered comment
43
+ // node (not the proxy's DOM position), so the proxy need not be mounted.
44
+ const proxyEl = document.createElement('bf-loop-item')
45
+ commentScopeRegistry.set(proxyEl, { commentNode: anchor, scopeId: parentScopeId ?? '' })
46
+ return { anchor, bindScope: proxyEl, parentScopeId }
47
+ }
48
+ const el = scope as Element
49
+ return { anchor: null, bindScope: el, parentScopeId: el.getAttribute(BF_SCOPE) }
50
+ }
51
+
52
+ /** Find the `[bf-c="id"]` element within a loop item's sibling range. */
53
+ function findCondElInRange(anchor: Comment, id: string): Element | null {
54
+ const sel = `[${BF_COND}="${id}"]`
55
+ const boundary = getCommentScopeBoundary(anchor)
56
+ let node: Node | null = anchor.nextSibling
57
+ while (node && node !== boundary) {
58
+ if (node.nodeType === Node.ELEMENT_NODE) {
59
+ const el = node as Element
60
+ if (el.matches?.(sel)) return el
61
+ const inner = el.querySelector(sel)
62
+ if (inner) return inner
63
+ }
64
+ node = node.nextSibling
65
+ }
66
+ return null
67
+ }
68
+
69
+ /** Find the `bf-cond-start:id` comment within a loop item's sibling range
70
+ * (checking range siblings and their descendants). */
71
+ function findCondStartInRange(anchor: Comment, id: string): Comment | null {
72
+ const want = `bf-cond-start:${id}`
73
+ const boundary = getCommentScopeBoundary(anchor)
74
+ let node: Node | null = anchor.nextSibling
75
+ while (node && node !== boundary) {
76
+ if (node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === want) {
77
+ return node as Comment
78
+ }
79
+ if (node.nodeType === Node.ELEMENT_NODE) {
80
+ const w = document.createTreeWalker(node as Element, NodeFilter.SHOW_COMMENT)
81
+ while (w.nextNode()) {
82
+ if ((w.currentNode as Comment).nodeValue === want) return w.currentNode as Comment
83
+ }
84
+ }
85
+ node = node.nextSibling
86
+ }
87
+ return null
88
+ }
13
89
 
14
90
  /**
15
91
  * Result returned by a branch's `template()` when the template captures
@@ -102,7 +178,7 @@ function evalBranchTemplate(branch: BranchConfig): BranchTemplateResult {
102
178
  * @param whenFalse - Branch config for when condition is false
103
179
  */
104
180
  export function insert(
105
- scope: Element | null,
181
+ scope: Element | Comment | null,
106
182
  id: string,
107
183
  conditionFn: () => boolean,
108
184
  whenTrue: BranchConfig,
@@ -110,10 +186,17 @@ export function insert(
110
186
  ): void {
111
187
  if (!scope) return
112
188
 
189
+ // Resolve the scope into a search region. For an Element scope this is
190
+ // byte-identical to the legacy descendant search. For a Comment anchor
191
+ // (`<!--bf-loop-i:<key>-->`) the region is the item's sibling range, so a
192
+ // whole-item conditional toggles only its own item even when every item
193
+ // shares the same conditional slot id (#1665).
194
+ const region = makeRegion(scope)
195
+
113
196
  // Extract parent scope ID for renderChild context.
114
197
  // When branch templates call renderChild(), it needs the parent scope ID
115
198
  // so child mounts can stamp `bf-h` / `bf-m` for slot-resolver lookups.
116
- const parentScopeId = scope.getAttribute(BF_SCOPE)
199
+ const parentScopeId = region.parentScopeId
117
200
 
118
201
  // Check if either branch uses fragment conditional (comment markers).
119
202
  // Both branches need to be checked because SSR may render either branch.
@@ -166,7 +249,9 @@ export function insert(
166
249
  setParentScopeId(parentScopeId)
167
250
  let result: BranchTemplateResult
168
251
  try { result = evalBranchTemplate(branch) } finally { setParentScopeId(null) }
169
- const existingEl = find(scope, `[${BF_COND}="${id}"]`)
252
+ const existingEl = region.anchor
253
+ ? findCondElInRange(region.anchor, id)
254
+ : find(region.bindScope, `[${BF_COND}="${id}"]`)
170
255
  if (existingEl) {
171
256
  // Compare full opening tag signatures to detect branch mismatch.
172
257
  // Tag-name-only comparison fails when both branches use the same tag (e.g., <div>).
@@ -182,32 +267,32 @@ export function insert(
182
267
  // branch — the SSR DOM is correct, and replacing it would re-render
183
268
  // via the registered child template, which doesn't reproduce the
184
269
  // bf-h / bf-m markers set by the parent's JSX scope chain.
185
- updateFragmentConditional(scope, id, result)
270
+ updateFragmentConditional(region, id, result)
186
271
  } else if (!isFragmentCond && expectedSig && existingSig && expectedSig !== existingSig) {
187
272
  // DOM doesn't match expected branch - need to swap
188
- updateElementConditional(scope, id, result)
273
+ updateElementConditional(region, id, result)
189
274
  } else if (result.slots.length > 0) {
190
275
  // Branch template captured live nodes via __bfSlot (#1213). The
191
276
  // SSR DOM rendered Hono-stringified HTML, but the client now needs
192
277
  // the live signal-bound nodes installed. Force a swap so the
193
278
  // existing element is replaced with the slot-spliced template.
194
- updateElementConditional(scope, id, result)
279
+ updateElementConditional(region, id, result)
195
280
  }
196
281
  } else if (isFragmentCond) {
197
282
  // For @client fragment conditionals, SSR renders only comment markers.
198
283
  // We need to insert the actual content on first run.
199
- updateFragmentConditional(scope, id, result)
284
+ updateFragmentConditional(region, id, result)
200
285
  }
201
286
 
202
287
  // Bind events to the (possibly updated) SSR element. Pass isFirstRun
203
288
  // so branch composite loops can skip the wipe-then-rebuild path that
204
289
  // is only needed for subsequent branch swaps (the SSR-rendered DOM
205
290
  // already matches the data and mapArray reconciles by key from it).
206
- const cleanup = branch.bindEvents(scope, { isFirstRun: true })
291
+ const cleanup = branch.bindEvents(region.bindScope, { isFirstRun: true })
207
292
  branchCleanup = typeof cleanup === 'function' ? cleanup : null
208
293
 
209
294
  // Auto-focus on first run too (for components created via createComponent with editing=true)
210
- autoFocusConditionalElement(scope, id)
295
+ autoFocusConditionalElement(region, id)
211
296
  return
212
297
  }
213
298
 
@@ -229,17 +314,17 @@ export function insert(
229
314
  let result: BranchTemplateResult
230
315
  try { result = evalBranchTemplate(branch) } finally { setParentScopeId(null) }
231
316
  if (isFragmentCond) {
232
- updateFragmentConditional(scope, id, result)
317
+ updateFragmentConditional(region, id, result)
233
318
  } else {
234
- updateElementConditional(scope, id, result)
319
+ updateElementConditional(region, id, result)
235
320
  }
236
321
 
237
322
  // Bind events to the newly inserted element (branch swap: not first run).
238
- const cleanup = branch.bindEvents(scope, { isFirstRun: false })
323
+ const cleanup = branch.bindEvents(region.bindScope, { isFirstRun: false })
239
324
  branchCleanup = typeof cleanup === 'function' ? cleanup : null
240
325
 
241
326
  // Auto-focus elements with autofocus attribute (for dynamically created elements)
242
- autoFocusConditionalElement(scope, id)
327
+ autoFocusConditionalElement(region, id)
243
328
  })
244
329
  }
245
330
 
@@ -249,12 +334,14 @@ export function insert(
249
334
  * Used by insert() to focus inputs when they become visible.
250
335
  * Uses requestAnimationFrame to ensure element is in DOM before focusing.
251
336
  */
252
- function autoFocusConditionalElement(scope: Element, id: string): void {
337
+ function autoFocusConditionalElement(region: CondRegion, id: string): void {
253
338
  // Use requestAnimationFrame to defer focus until after DOM updates.
254
339
  // This is necessary because createComponent() may call insert() before
255
340
  // the element is added to the document by reconcileList().
256
341
  requestAnimationFrame(() => {
257
- const condEl = scope.querySelector(`[${BF_COND}="${id}"]`)
342
+ const condEl = region.anchor
343
+ ? findCondElInRange(region.anchor, id)
344
+ : region.bindScope.querySelector(`[${BF_COND}="${id}"]`)
258
345
  if (condEl) {
259
346
  const autofocusEl = condEl.matches('[autofocus]')
260
347
  ? condEl
@@ -307,20 +394,29 @@ function spliceSlots(fragment: DocumentFragment, slots: Node[]): DocumentFragmen
307
394
  /**
308
395
  * Update fragment conditional (content between comment markers)
309
396
  */
310
- function updateFragmentConditional(scope: Element, id: string, result: BranchTemplateResult): void {
397
+ function updateFragmentConditional(region: CondRegion, id: string, result: BranchTemplateResult): void {
311
398
  const { html, slots } = result
312
- // Find start comment marker
399
+ const scope = region.bindScope
400
+ // Find start comment marker. For a loop-item region the marker lives
401
+ // among the anchor's range siblings (no descendant relationship to a
402
+ // scope element), so locate it within the item range (#1665).
313
403
  const startMarker = `bf-cond-start:${id}`
314
404
  let startComment: Comment | null = null
315
- const walker = document.createTreeWalker(scope, NodeFilter.SHOW_COMMENT)
316
- while (walker.nextNode()) {
317
- if (walker.currentNode.nodeValue === startMarker) {
318
- startComment = walker.currentNode as Comment
319
- break
405
+ if (region.anchor) {
406
+ startComment = findCondStartInRange(region.anchor, id)
407
+ } else {
408
+ const walker = document.createTreeWalker(scope, NodeFilter.SHOW_COMMENT)
409
+ while (walker.nextNode()) {
410
+ if (walker.currentNode.nodeValue === startMarker) {
411
+ startComment = walker.currentNode as Comment
412
+ break
413
+ }
320
414
  }
321
415
  }
322
416
 
323
- const condEl = scope.querySelector(`[${BF_COND}="${id}"]`)
417
+ const condEl = region.anchor
418
+ ? findCondElInRange(region.anchor, id)
419
+ : scope.querySelector(`[${BF_COND}="${id}"]`)
324
420
 
325
421
  const endMarker = `bf-cond-end:${id}`
326
422
 
@@ -388,8 +484,10 @@ function updateFragmentConditional(scope: Element, id: string, result: BranchTem
388
484
  /**
389
485
  * Update element conditional (single element with bf-c)
390
486
  */
391
- function updateElementConditional(scope: Element, id: string, result: BranchTemplateResult): void {
392
- const condEl = scope.querySelector(`[${BF_COND}="${id}"]`)
487
+ function updateElementConditional(region: CondRegion, id: string, result: BranchTemplateResult): void {
488
+ const condEl = region.anchor
489
+ ? findCondElInRange(region.anchor, id)
490
+ : region.bindScope.querySelector(`[${BF_COND}="${id}"]`)
393
491
  if (!condEl) return
394
492
 
395
493
  const { html, slots } = result
@@ -27,6 +27,7 @@ import {
27
27
  BF_LOOP_ITEM,
28
28
  loopStartMarker,
29
29
  loopEndMarker,
30
+ loopItemMarker,
30
31
  } from '@barefootjs/shared'
31
32
 
32
33
  type ItemScope<T> = {
@@ -390,3 +391,229 @@ export function mapArray<T>(
390
391
  }
391
392
  })
392
393
  }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Anchored list rendering (#1665) — whole-item loop conditionals.
397
+ //
398
+ // `arr.map(t => cond(t) && <li/>)` makes the conditional the entire loop
399
+ // item, so an item renders 0-or-1 element per pass. The legacy `mapArray`
400
+ // tracks each item by a required `primaryEl` Element, which cannot represent
401
+ // the empty (false-branch) item. `mapArrayAnchored` instead tracks each item
402
+ // by a `<!--bf-loop-i:KEY-->` anchor comment that is ALWAYS present. The
403
+ // item's content lives between its anchor and the next anchor / loop end and
404
+ // is derived from the live DOM range every pass (never cached): `insert()`
405
+ // owns the content, `mapArrayAnchored` owns the anchors (identity + order).
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /** Item tracked by its always-present `bf-loop-i:KEY` anchor comment. */
409
+ type AnchorScope<T> = {
410
+ anchor: Comment
411
+ /** Detached nodes to mount for a freshly created (CSR) item; null once mounted. */
412
+ pending: DocumentFragment | null
413
+ dispose: () => void
414
+ setItem: (v: T) => void
415
+ }
416
+
417
+ const ITEM_PREFIX = `${BF_LOOP_ITEM}:`
418
+
419
+ function isItemAnchor(node: Node): node is Comment {
420
+ return (
421
+ node.nodeType === Node.COMMENT_NODE &&
422
+ ((node as Comment).nodeValue ?? '').startsWith(ITEM_PREFIX)
423
+ )
424
+ }
425
+
426
+ /** Collect a live item range: the anchor and every node up to the next item
427
+ * anchor or the loop end marker (exclusive). Recomputed from the live DOM on
428
+ * every call rather than cached, because `insert()` adds and removes the
429
+ * item's content independently of this module — a cached node list would go
430
+ * stale the moment a conditional toggled. */
431
+ function collectAnchorRange(anchor: Comment, end: Comment | null): Node[] {
432
+ const nodes: Node[] = [anchor]
433
+ let node: Node | null = anchor.nextSibling
434
+ while (node && node !== end) {
435
+ if (isItemAnchor(node)) break
436
+ nodes.push(node)
437
+ node = node.nextSibling
438
+ }
439
+ return nodes
440
+ }
441
+
442
+ /** Partition the loop range into the item anchors present in SSR/CSR DOM. */
443
+ function findItemAnchors(start: Comment, end: Comment): Comment[] {
444
+ const anchors: Comment[] = []
445
+ let node: Node | null = start.nextSibling
446
+ while (node && node !== end) {
447
+ if (isItemAnchor(node)) anchors.push(node as Comment)
448
+ node = node.nextSibling
449
+ }
450
+ return anchors
451
+ }
452
+
453
+ /** Move (or first-mount) a scope's whole range immediately before `before`. */
454
+ function placeAnchorScope<T>(
455
+ scope: AnchorScope<T>,
456
+ container: HTMLElement,
457
+ before: Node | null,
458
+ end: Comment | null,
459
+ ): void {
460
+ if (scope.pending) {
461
+ container.insertBefore(scope.pending, before)
462
+ scope.pending = null
463
+ return
464
+ }
465
+ for (const node of collectAnchorRange(scope.anchor, end)) {
466
+ container.insertBefore(node, before)
467
+ }
468
+ }
469
+
470
+ /** Detach a scope's whole range from the DOM. */
471
+ function removeAnchorScope<T>(scope: AnchorScope<T>, end: Comment | null): void {
472
+ for (const node of collectAnchorRange(scope.anchor, end)) {
473
+ node.parentNode?.removeChild(node)
474
+ }
475
+ }
476
+
477
+ function createAnchorScope<T>(
478
+ item: T,
479
+ index: number,
480
+ key: string,
481
+ renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
482
+ existingAnchor?: Comment,
483
+ ): AnchorScope<T> {
484
+ let dispose!: () => void
485
+ let setItem!: (v: T) => void
486
+ let returned!: DocumentFragment | Comment
487
+
488
+ createRoot((d) => {
489
+ dispose = d
490
+ const [itemAccessor, itemSetter] = createSignal(item)
491
+ setItem = itemSetter
492
+ returned = renderItem(itemAccessor, index, existingAnchor)
493
+ return undefined
494
+ })
495
+
496
+ if (existingAnchor) {
497
+ return { anchor: existingAnchor, pending: null, dispose, setItem }
498
+ }
499
+ // CSR: renderItem returns a fragment whose first child is the anchor.
500
+ const frag = returned as DocumentFragment
501
+ const anchor = frag.firstChild as Comment
502
+ // The renderItem already encodes the key into the anchor value, but tolerate
503
+ // a bare anchor by stamping it here so identity/order reads stay consistent.
504
+ if (anchor && !anchor.nodeValue?.startsWith(ITEM_PREFIX)) {
505
+ anchor.nodeValue = loopItemMarker(key)
506
+ }
507
+ return { anchor, pending: frag, dispose, setItem }
508
+ }
509
+
510
+ /**
511
+ * Per-item scoped list rendering for whole-item conditionals (#1665).
512
+ *
513
+ * Same call shape as `mapArray`, but `renderItem` returns a `DocumentFragment`
514
+ * (CSR, first child = `bf-loop-i:KEY` anchor) or the existing anchor Comment
515
+ * (hydration). Items may render zero elements; the anchor is the stable
516
+ * identity and position.
517
+ */
518
+ export function mapArrayAnchored<T>(
519
+ accessor: () => T[],
520
+ container: HTMLElement | null,
521
+ getKey: ((item: T, index: number) => string) | null,
522
+ renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
523
+ markerId?: string,
524
+ ): void {
525
+ if (!container) return
526
+
527
+ const scopes = new Map<string, AnchorScope<T>>()
528
+ let hydrated = false
529
+
530
+ createEffect(() => {
531
+ const items = accessor()
532
+ if (!items) return
533
+
534
+ const { start, end } = findLoopMarkers(container, markerId)
535
+ if (!start || !end) return
536
+
537
+ // --- First run: adopt / hydrate SSR-rendered item anchors ---
538
+ if (!hydrated) {
539
+ hydrated = true
540
+ const existing = findItemAnchors(start, end)
541
+ if (existing.length > 0 && scopes.size === 0) {
542
+ for (let i = 0; i < existing.length && i < items.length; i++) {
543
+ const item = items[i]
544
+ const key = getKey ? getKey(item, i) : String(i)
545
+ scopes.set(key, createAnchorScope(item, i, key, renderItem, existing[i]))
546
+ }
547
+ // SSR rendered fewer items than the current array — create the rest.
548
+ for (let i = existing.length; i < items.length; i++) {
549
+ const item = items[i]
550
+ const key = getKey ? getKey(item, i) : String(i)
551
+ const scope = createAnchorScope(item, i, key, renderItem)
552
+ scopes.set(key, scope)
553
+ placeAnchorScope(scope, container, end, end)
554
+ }
555
+ // SSR rendered more items than the current array — drop the extras.
556
+ for (let i = items.length; i < existing.length; i++) {
557
+ for (const node of collectAnchorRange(existing[i], end)) {
558
+ node.parentNode?.removeChild(node)
559
+ }
560
+ }
561
+ return
562
+ }
563
+ }
564
+
565
+ // --- Key-based diff ---
566
+ const newKeys = new Set<string>()
567
+ const warnedKeys = new Set<string>()
568
+ const desiredOrder: AnchorScope<T>[] = []
569
+
570
+ for (let i = 0; i < items.length; i++) {
571
+ const item = items[i]
572
+ const key = getKey ? getKey(item, i) : String(i)
573
+ if (newKeys.has(key) && !warnedKeys.has(key)) {
574
+ warnedKeys.add(key)
575
+ console.warn(
576
+ `[BarefootJS] mapArrayAnchored: duplicate key "${key}" — items with this key collapse to a ` +
577
+ `single DOM scope, so only the last one renders. Use a per-item identifier (e.g. \`key={item.id}\`).`,
578
+ )
579
+ }
580
+ newKeys.add(key)
581
+
582
+ const existing = scopes.get(key)
583
+ if (existing) {
584
+ existing.setItem(item)
585
+ desiredOrder.push(existing)
586
+ } else {
587
+ const scope = createAnchorScope(item, i, key, renderItem)
588
+ scopes.set(key, scope)
589
+ desiredOrder.push(scope)
590
+ }
591
+ }
592
+
593
+ // Remove items no longer present.
594
+ for (const [key, scope] of scopes) {
595
+ if (!newKeys.has(key)) {
596
+ scope.dispose()
597
+ removeAnchorScope(scope, end)
598
+ scopes.delete(key)
599
+ }
600
+ }
601
+
602
+ // Reconcile DOM order. Comparing anchors (not elements) is correct even
603
+ // for empty items, which contribute only their anchor to the range.
604
+ let inOrder = true
605
+ const domAnchors = findItemAnchors(start, end)
606
+ if (domAnchors.length !== desiredOrder.length) {
607
+ inOrder = false
608
+ } else {
609
+ for (let i = 0; i < desiredOrder.length; i++) {
610
+ if (domAnchors[i] !== desiredOrder[i].anchor) { inOrder = false; break }
611
+ }
612
+ }
613
+ if (!inOrder) {
614
+ for (const scope of desiredOrder) {
615
+ placeAnchorScope(scope, container, end, end)
616
+ }
617
+ }
618
+ })
619
+ }
@@ -5,7 +5,7 @@
5
5
  * Maps an element to its comment node and the sibling range boundary.
6
6
  */
7
7
 
8
- import { BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
8
+ import { BF_SCOPE_COMMENT_PREFIX, BF_LOOP_ITEM, BF_LOOP_END } from '@barefootjs/shared'
9
9
 
10
10
  /**
11
11
  * Information about a comment-based scope.
@@ -31,14 +31,27 @@ export function getPortalScopeId(element: Element): string | null {
31
31
 
32
32
  /**
33
33
  * Find the end boundary for a comment-based scope.
34
- * The boundary is the next bf-scope: comment or the end of the parent's children.
34
+ *
35
+ * The boundary depends on the anchor's kind:
36
+ * - `bf-scope:` anchor (fragment-root component): boundary is the next
37
+ * `bf-scope:` comment or the end of the parent's children (unchanged).
38
+ * - `bf-loop-i:<key>` anchor (loop item, #1665): boundary is the next
39
+ * loop-item anchor (`bf-loop-i:*`) or the loop end marker (`bf-/loop:*`),
40
+ * so one item's range never bleeds into the next item or past the loop.
35
41
  */
36
42
  export function getCommentScopeBoundary(commentNode: Comment): Node | null {
43
+ const isLoopItem = commentNode.nodeValue?.startsWith(`${BF_LOOP_ITEM}:`) ?? false
37
44
  let node: Node | null = commentNode.nextSibling
38
45
  while (node) {
39
- if (node.nodeType === Node.COMMENT_NODE &&
40
- (node as Comment).nodeValue?.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
41
- return node
46
+ if (node.nodeType === Node.COMMENT_NODE) {
47
+ const value = (node as Comment).nodeValue ?? ''
48
+ if (isLoopItem) {
49
+ if (value.startsWith(`${BF_LOOP_ITEM}:`) || value.startsWith(`${BF_LOOP_END}:`)) {
50
+ return node
51
+ }
52
+ } else if (value.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
53
+ return node
54
+ }
42
55
  }
43
56
  node = node.nextSibling
44
57
  }