@barefootjs/client 0.33.1 → 0.33.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.
@@ -11,7 +11,17 @@ import { getRegisteredDef } from './hydrate.ts'
11
11
  import { hydratedScopes } from './hydration-state.ts'
12
12
  import { untrack } from '@barefootjs/client/reactive'
13
13
  import { setCurrentScope } from './context.ts'
14
- import { BF_SCOPE, BF_KEY, BF_HOST, BF_AT, BF_PARENT_SCOPE_PLACEHOLDER, BF_PLACEHOLDER } from '@barefootjs/shared'
14
+ import { commentScopeRegistry } from './scope.ts'
15
+ import {
16
+ BF_SCOPE,
17
+ BF_KEY,
18
+ BF_HOST,
19
+ BF_AT,
20
+ BF_PARENT_SCOPE_PLACEHOLDER,
21
+ BF_PLACEHOLDER,
22
+ BF_SCOPE_COMMENT_PREFIX,
23
+ BF_SCOPE_COMMENT_END_PREFIX,
24
+ } from '@barefootjs/shared'
15
25
  import type { ComponentDef } from './types.ts'
16
26
 
17
27
  // Parent scope ID context for renderChild() inside insert() branch templates.
@@ -137,14 +147,56 @@ export interface CreateComponentSlotInfo {
137
147
  mount: string
138
148
  }
139
149
 
150
+ /**
151
+ * The `HTMLElement | DocumentFragment` return covers exactly one shape: a
152
+ * BARE call (no `mountAt`, no ambient row-mount point) for a genuine
153
+ * fragment-root component (#2722). Every other combination — a normal
154
+ * component, or a fragment-root one with `mountAt`/row-mount already
155
+ * telling this function where to connect — still returns the real,
156
+ * single `HTMLElement`, unchanged (that element stays the caller-visible
157
+ * proxy even when the fragment root has further sibling roots of its own
158
+ * — #2735 — those travel alongside it, never in place of it). Only the
159
+ * no-known-destination case has no single element to hand back: the
160
+ * fragment root's own `<!--bf-scope:-->` boundary comments PLUS every
161
+ * top-level node the fragment's template rendered — elements, bare text
162
+ * and `<!--bf:sN-->` slot markers alike (`materializeComponent` step 7b)
163
+ * — must travel to wherever the caller inserts the result, and a
164
+ * `DocumentFragment` is the one `Node` a plain `container.appendChild(...)`
165
+ * / `el.replaceWith(...)` moves as a unit without the caller needing to
166
+ * know why.
167
+ *
168
+ * The first overload states that in the type system rather than only here:
169
+ * a call that passes a non-null `mountAt` is telling this function where to
170
+ * connect, so it can only get the real `HTMLElement` back. Callers on that
171
+ * overload need no cast, which is why `upsertChild` (registry.ts) and
172
+ * `upsertChildItem` (qsa-item.ts) assert nothing — the narrowing is the
173
+ * signature's job, not theirs.
174
+ */
175
+ export function createComponent(
176
+ nameOrDef: string | ComponentDef,
177
+ props: Record<string, unknown>,
178
+ key: string | number | undefined,
179
+ slot: CreateComponentSlotInfo | undefined,
180
+ mountAt: Element,
181
+ keyAttrName?: string,
182
+ ): HTMLElement
183
+ export function createComponent(
184
+ nameOrDef: string | ComponentDef,
185
+ props?: Record<string, unknown>,
186
+ key?: string | number,
187
+ slot?: CreateComponentSlotInfo,
188
+ mountAt?: Element | null,
189
+ keyAttrName?: string,
190
+ ): HTMLElement | DocumentFragment
140
191
  export function createComponent(
141
192
  nameOrDef: string | ComponentDef,
142
193
  props: Record<string, unknown> = {},
143
194
  key?: string | number,
144
195
  slot?: CreateComponentSlotInfo,
145
196
  mountAt?: Element | null,
146
- ): HTMLElement {
147
- const element = materializeComponent(nameOrDef, props, key, slot, mountAt)
197
+ keyAttrName: string = BF_KEY,
198
+ ): HTMLElement | DocumentFragment {
199
+ const element = materializeComponent(nameOrDef, props, key, slot, mountAt, keyAttrName)
148
200
  // `mountAt` is an unconditional obligation: callers used to run
149
201
  // `ph.replaceWith(comp)` themselves on every outcome, so every path that
150
202
  // did NOT consume the placeholder still owes the replacement — a missing or
@@ -152,7 +204,9 @@ export function createComponent(
152
204
  // which must stay detached so its self-replacement stays recoverable.
153
205
  // `parentNode` (not `isConnected`) is the right "still unconsumed" probe: it
154
206
  // survives a `mountAt` that was itself detached, which is the normal case
155
- // during multi-root loop-body setup.
207
+ // during multi-root loop-body setup. A fragment-root's `DocumentFragment`
208
+ // return only ever happens when `mountAt` is absent (see above), so it
209
+ // never reaches this branch.
156
210
  if (mountAt && mountAt.parentNode && element !== mountAt) {
157
211
  mountAt.replaceWith(element)
158
212
  }
@@ -173,7 +227,8 @@ function materializeComponent(
173
227
  key?: string | number,
174
228
  slot?: CreateComponentSlotInfo,
175
229
  mountAt?: Element | null,
176
- ): HTMLElement {
230
+ keyAttrName: string = BF_KEY,
231
+ ): HTMLElement | DocumentFragment {
177
232
  // A bare callable shim invoked from user code (e.g. an object-literal
178
233
  // value `LOGOS[id]()` whose arrow the compiler hoisted into a component)
179
234
  // reaches us with no props (#1663). Normalize to an empty object so the
@@ -184,7 +239,7 @@ function materializeComponent(
184
239
  const rowMount = mountAt ? null : takeRowMountPoint()
185
240
  // ComponentDef mode: use def directly instead of registry lookup
186
241
  if (typeof nameOrDef !== 'string') {
187
- return createComponentFromDef(nameOrDef, props, key, mountAt, rowMount)
242
+ return createComponentFromDef(nameOrDef, props, key, mountAt, rowMount, keyAttrName)
188
243
  }
189
244
 
190
245
  const name = nameOrDef
@@ -193,7 +248,7 @@ function materializeComponent(
193
248
  const templateFn = getTemplate(name)
194
249
  if (!templateFn) {
195
250
  console.warn(`[BarefootJS] Template not found for component: ${name}`)
196
- return createPlaceholder(name, key)
251
+ return createPlaceholder(name, key, keyAttrName)
197
252
  }
198
253
 
199
254
  // 2. Check for getter children.
@@ -230,11 +285,22 @@ function materializeComponent(
230
285
 
231
286
  // 4. Pre-generate the component's scope ID.
232
287
  //
233
- // `comment: true` components (synthesized inline-JSX-callback wrappers
234
- // from #1211) render as transparent shells the parsed `firstChild` is
235
- // already the inner component's root with its own bf-s. Don't overwrite
236
- // it (scopeId stays null), or `$c(__scope, 's0')` from the wrapper's
237
- // init resolves to null.
288
+ // `comment: true` components are proxy-scoped — no element of their own
289
+ // carries `bf-s` directly but that covers TWO different shapes
290
+ // (`ComponentDef.fragmentRoot`'s docstring, types.ts) that need OPPOSITE
291
+ // treatment here:
292
+ // - root-is-a-child-call (#1211/#2649, `fragmentRoot` false): the
293
+ // parsed `firstChild` IS the child's own already-scoped element.
294
+ // Don't overwrite it (scopeId stays null), or `$c(__scope, 's0')`
295
+ // from the wrapper's init resolves to null.
296
+ // - genuine fragment root (`fragmentRoot` true): the parsed `firstChild`
297
+ // carries NO scope of its own (SSR moves it into the wrapping
298
+ // `<!--bf-scope:-->` comment) — generate one just the same, so
299
+ // `_parentScopeId` below still gets threaded into nested
300
+ // `renderChild()` calls and their naming matches SSR/hydrate (#2722:
301
+ // leaving this null made every nested child fall back to a random,
302
+ // un-prefixed scope id — `Select_xyz` instead of the expected
303
+ // `SelectBasicDemo_xyz_s8`).
238
304
  //
239
305
  // `slot` is only supplied by `upsertChild` / `upsertChildItem` mounting a
240
306
  // component nested below a loop row root — the SSR reference (Hono)
@@ -245,11 +311,12 @@ function materializeComponent(
245
311
  // random id, matching the reference behaviour.
246
312
  const def = getRegisteredDef(name)
247
313
  const isCommentWrapper = def?.comment === true
314
+ const isFragmentRoot = def?.fragmentRoot === true
248
315
  const derivedScopeId = slot?.parent && slot.mount ? `${slot.parent}_${slot.mount}` : null
249
316
  // Same as in `renderChild`: `name` is the registry key, which is
250
317
  // file-scoped (`Name__<8hex>`) for a non-exported component. The scope ID
251
318
  // must carry the plain name — see `ComponentDef.name` (#2518).
252
- const scopeId = isCommentWrapper ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
319
+ const scopeId = (isCommentWrapper && !isFragmentRoot) ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
253
320
 
254
321
  // 5. Generate HTML from props.
255
322
  //
@@ -265,11 +332,32 @@ function materializeComponent(
265
332
  // #2444's `grandchild-composition` case). A comment wrapper keeps
266
333
  // `scopeId === null` and falls through to `slot?.parent`, preserving the
267
334
  // hoisted-children placeholder resolution (#1320).
335
+ //
336
+ // Third branch (#2757): a root-is-a-child-call wrapper mounted at the TOP
337
+ // LEVEL has neither. `scopeId` is null by design (step 4 — the parsed
338
+ // firstChild is the child's own already-scoped element, so we must not
339
+ // stamp over it) and a top-level `createComponent(name, {})` is passed no
340
+ // `slot`, so pre-#2757 `_parentScopeId` stayed null for the whole template
341
+ // eval and `renderChild` fell back to naming the child after ITSELF
342
+ // (`PairwiseRow_xyz_s2` where SSR and hydration both produce
343
+ // `PairwiseCase_xyz_s2`, and with no `bf-h`/`bf-m` at all). The wrapper
344
+ // still HAS a scope identity in the SSR convention — it just has no
345
+ // element of its own to carry it — so derive one here for threading only.
346
+ // Same split #2722 made for a genuine fragment root, which keeps a
347
+ // non-null `scopeId` purely so this threading works and skips only the
348
+ // ATTRIBUTE write in step 7.
349
+ //
350
+ // Why not derive unconditionally: guarded on `!_parentScopeId` so a
351
+ // wrapper materialized while an OUTER template eval is in flight keeps
352
+ // inheriting that caller's ambient scope rather than being renamed under a
353
+ // fresh random id. Only the genuinely-rootless mount is affected.
268
354
  const prevParentScopeId = _parentScopeId
269
355
  if (scopeId) {
270
356
  _parentScopeId = scopeId
271
357
  } else if (slot?.parent) {
272
358
  _parentScopeId = slot.parent
359
+ } else if (!_parentScopeId) {
360
+ _parentScopeId = `${def?.name ?? name}_${generateId()}`
273
361
  }
274
362
  let html: string
275
363
  try {
@@ -278,16 +366,66 @@ function materializeComponent(
278
366
  _parentScopeId = prevParentScopeId
279
367
  }
280
368
 
281
- // 6. Create DOM element
282
- const element = parseHTML(html.trim()).firstChild as HTMLElement
369
+ // 6. Create DOM node(s).
370
+ //
371
+ // A genuine fragment root's template concatenates EVERY top-level
372
+ // sibling into one HTML string, so `roots` is the whole ordered list —
373
+ // `parseHTML(...).firstChild` used to be the only node kept, silently
374
+ // dropping the rest (#2735). Everything travels, whatever its node
375
+ // type: a fragment's top level is not only elements. Bare text between
376
+ // two element roots (`<><h1/>text<p/></>`) is a root, and a reactive
377
+ // text slot sitting there renders as a `<!--bf:sN-->` marker. Both were
378
+ // measured being dropped by an element-only walk — the text as a
379
+ // visible SSR/CSR-mount diff, the marker as something worse, since the
380
+ // runtime's own slot lookup then finds nothing to bind.
381
+ //
382
+ // `element` is the PROXY: the one node threaded through init /
383
+ // `commentScopeRegistry` / the return value. It must be an Element —
384
+ // everything downstream calls `setAttribute`/`hasAttribute` on it — so
385
+ // it is the first ELEMENT among the roots, not simply the first node.
386
+ // `<>text<p/></>` puts a Text node first, and taking that as the proxy
387
+ // threw `element.hasAttribute is not a function` at step 7b (measured;
388
+ // pre-dates #2735's fix, which is why the roots list and the proxy are
389
+ // chosen separately rather than the proxy being `roots[0]`).
390
+ //
391
+ // Only `isFragmentRoot` templates can emit more than one top-level node
392
+ // (jsx-to-ir.ts's `transformFragment`), so every other shape keeps
393
+ // exactly the single-node list it always had.
394
+ const parsedFragment = parseHTML(html.trim())
395
+ const roots: Node[] = isFragmentRoot
396
+ ? Array.from(parsedFragment.childNodes)
397
+ : parsedFragment.firstChild
398
+ ? [parsedFragment.firstChild]
399
+ : []
400
+ const element = (isFragmentRoot
401
+ ? roots.find(node => node.nodeType === Node.ELEMENT_NODE)
402
+ : roots[0]) as HTMLElement | undefined
403
+
404
+ // A fragment root with no element at all (`<>just text</>`) has nothing
405
+ // that can carry a scope. Refuse it the same way an empty template is
406
+ // refused rather than crashing on the first `setAttribute` — loud, not
407
+ // silent, per the sound-or-loud rule.
408
+ if (isFragmentRoot && roots.length > 0 && !element) {
409
+ console.warn(
410
+ `[BarefootJS] Fragment-root component ${name} rendered no element root; ` +
411
+ 'a scope needs at least one element to attach to. Wrap the content in an element.',
412
+ )
413
+ return createPlaceholder(name, key, keyAttrName)
414
+ }
283
415
 
284
416
  if (!element) {
285
417
  console.warn(`[BarefootJS] Template returned empty HTML for component: ${name}`)
286
- return createPlaceholder(name, key)
418
+ return createPlaceholder(name, key, keyAttrName)
287
419
  }
288
420
 
289
421
  // 7. Set scope ID and key attributes.
290
- if (scopeId) {
422
+ //
423
+ // A genuine fragment root carries its scope id on a WRAPPING comment
424
+ // pair, never as a `bf-s` attribute on the element itself — matching
425
+ // `wrapWithScopeComment` (hono-adapter.ts) and `hydrateCommentScope`
426
+ // (hydrate.ts). `scopeId` is still non-null for this shape (step 4) so
427
+ // `_parentScopeId` threads correctly; only the ATTRIBUTE is skipped here.
428
+ if (scopeId && !isFragmentRoot) {
291
429
  element.setAttribute(BF_SCOPE, scopeId)
292
430
  }
293
431
  if (slot) {
@@ -295,7 +433,28 @@ function materializeComponent(
295
433
  element.setAttribute(BF_AT, slot.mount)
296
434
  }
297
435
  if (key !== undefined) {
298
- element.setAttribute(BF_KEY, String(key))
436
+ element.setAttribute(keyAttrName, String(key))
437
+ }
438
+
439
+ // 7a. Fragment-root boundary comments + registry (#2722).
440
+ //
441
+ // `find()`/`$()`/`$c()` (query.ts) resolve a slot or child scope by
442
+ // walking `commentScopeRegistry`'s stored comment and its boundary
443
+ // (`getCommentScopeBoundary`, scope.ts) — that walk needs REAL, sibling-
444
+ // connected comment nodes, not just a registry entry, or `find()`'s
445
+ // comment-scope branch enumerates zero candidates (worse than the
446
+ // fallback `querySelectorAll` path a non-fragment scope gets). So these
447
+ // are built now and threaded through to wherever `element` ends up
448
+ // connected below, exactly mirroring the SSR/hydrate shape:
449
+ // <!--bf-scope:ID-->` + element + `<!--bf-/scope:ID-->`
450
+ const fragmentComments = isFragmentRoot && scopeId
451
+ ? {
452
+ start: document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`),
453
+ end: document.createComment(`${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}`),
454
+ }
455
+ : null
456
+ if (fragmentComments) {
457
+ commentScopeRegistry.set(element, { commentNode: fragmentComments.start, scopeId: scopeId! })
299
458
  }
300
459
 
301
460
  // 7b. Connect before init.
@@ -317,14 +476,76 @@ function materializeComponent(
317
476
  // live DOM with no handle on the result, so this shape keeps the
318
477
  // detached behaviour.
319
478
  const rootIsDeferredPlaceholder = element.hasAttribute(BF_PLACEHOLDER)
479
+ // A fragment root's boundary comments must land adjacent to `element`
480
+ // at the SAME moment it connects, in each of the three shapes below —
481
+ // there is no later hook to attach them once the caller has taken the
482
+ // return value away (see `createComponent`'s docstring for the fourth,
483
+ // no-known-destination shape, handled after `init` runs).
484
+ let bareFragment: DocumentFragment | null = null
320
485
  if (mountAt && !rootIsDeferredPlaceholder) {
321
- mountAt.replaceWith(element)
486
+ if (fragmentComments) {
487
+ mountAt.replaceWith(fragmentComments.start, ...roots, fragmentComments.end)
488
+ } else {
489
+ mountAt.replaceWith(element)
490
+ }
322
491
  } else if (rowMount && !rootIsDeferredPlaceholder) {
323
492
  // Loop row: no placeholder exists, so connect at the position `mapArray`
324
493
  // handed down. The reorder step may move the row afterwards; any position
325
494
  // inside the container yields the same ancestor chain, which is all
326
495
  // `useContext`'s parentElement walk needs.
327
- rowMount.container.insertBefore(element, rowMount.anchor)
496
+ if (fragmentComments) {
497
+ rowMount.container.insertBefore(fragmentComments.start, rowMount.anchor)
498
+ rowMount.container.insertBefore(element, rowMount.anchor)
499
+ rowMount.container.insertBefore(fragmentComments.end, rowMount.anchor)
500
+ // Hand the boundary pair to `mapArray`'s row bookkeeping (map-array.ts's
501
+ // `ItemScope.scopeComments`, #2733) via the same stash-on-the-element
502
+ // convention `__bfExtras` uses for a multi-root loop BODY's extra
503
+ // siblings: `createItemScope` reads and deletes this property right
504
+ // after `renderItem` returns, since there is no other channel back to
505
+ // the caller once `element` is the only thing returned below. Without
506
+ // this, a later reorder/removal of the row moves/removes `element`
507
+ // and leaves the comments behind, orphaned in the container.
508
+ ;(element as unknown as { __bfScopeComments?: { start: Comment; end: Comment } }).__bfScopeComments =
509
+ fragmentComments
510
+ // Deliberately NOT inserting the other roots here (a fragment-root
511
+ // component whose OWN render has 2+ top-level nodes, used as a loop
512
+ // row) — connecting them is a separate gap from the boundary-comment
513
+ // tracking #2733 fixed above: even with `ItemScope` now able to carry
514
+ // the row's comments, there is still nowhere on `ItemScope` for a
515
+ // second or third top-level ELEMENT of the row itself (as opposed to
516
+ // `extras`, which is the multi-root loop BODY's own, unrelated,
517
+ // per-item marker convention). Not reachable by any currently tracked
518
+ // fixture (no fragment-root component with 2+ top-level nodes is used
519
+ // as a loop row in the mutation corpus), so declared rather than grown
520
+ // here:
521
+ // https://github.com/piconic-ai/barefootjs/issues/2733
522
+ //
523
+ // Loud, not silent: the whole point of the fix above is that
524
+ // dropping roots without saying so is the failure mode. A gap that
525
+ // stays quiet is indistinguishable from correctness at the call
526
+ // site, so the one shape still dropping them says so.
527
+ if (roots.length > 1) {
528
+ console.warn(
529
+ `[BarefootJS] Fragment-root component ${name} used as a loop row renders ` +
530
+ `${roots.length} top-level nodes; only the first element is connected here. ` +
531
+ 'See https://github.com/piconic-ai/barefootjs/issues/2733',
532
+ )
533
+ }
534
+ } else {
535
+ rowMount.container.insertBefore(element, rowMount.anchor)
536
+ }
537
+ } else if (fragmentComments && !rootIsDeferredPlaceholder) {
538
+ // Neither a placeholder nor an ambient row position: the caller owns
539
+ // connecting the result itself (e.g. the compiler's exported
540
+ // `export function Name(props, key) { return createComponent(...) }`
541
+ // shim, called directly with no further composition — the shape
542
+ // `fixture-host.ts`'s `'csr-mount'` boot script uses). Bundle every
543
+ // node in one `DocumentFragment` so a plain `container.append(result)`
544
+ // / `el.replaceWith(result)` moves all of them together —
545
+ // `createComponent`'s docstring covers why this is the one shape that
546
+ // can't return a bare `HTMLElement`.
547
+ bareFragment = document.createDocumentFragment()
548
+ bareFragment.append(fragmentComments.start, ...roots, fragmentComments.end)
328
549
  }
329
550
 
330
551
  // 8. Set currentScope so provideContext/useContext are element-scoped.
@@ -390,7 +611,36 @@ function materializeComponent(
390
611
  // 12. Mark element as initialized
391
612
  hydratedScopes.add(element)
392
613
 
393
- return element
614
+ // `bareFragment` bundles `element` with its boundary comments for the
615
+ // one shape with no known destination (7b) — everything else returns
616
+ // the real, single element unchanged.
617
+ return bareFragment ?? element
618
+ }
619
+
620
+ /**
621
+ * Splice `attrs` (e.g. ` data-key="1"`) onto `html`'s first element's own
622
+ * tag, wherever that tag starts (templates may open with comment markers
623
+ * like `<!--bf-cond-start:...-->` before the first real element). No-op —
624
+ * returns `html` unchanged — if no element tag is found at all.
625
+ */
626
+ /**
627
+ * A tag name is not `\\w+`: custom elements are required to contain a
628
+ * hyphen (`<my-widget>`), and `.` and `:` are legal too. Matching only
629
+ * `[A-Za-z0-9_]` spliced attributes into the MIDDLE of such a name
630
+ * (`<my bf-s="…"-widget>`), which the parser then drops entirely — while
631
+ * SSR, which places the same attributes as a compiler-emitted JSX spread,
632
+ * kept emitting them correctly. Anchored on a leading letter so the
633
+ * comment markers a template may open with (`<!--bf-cond-start:…-->`) are
634
+ * still skipped rather than matched.
635
+ */
636
+ const FIRST_TAG_PATTERN = /<([a-zA-Z][^\s/>]*)/
637
+ const TAG_HEAD_PATTERN = /^(<[a-zA-Z][^\s/>]*)/
638
+
639
+ function spliceAttrsAfterFirstTag(html: string, attrs: string): string {
640
+ const firstElMatch = html.match(FIRST_TAG_PATTERN)
641
+ if (!firstElMatch) return html
642
+ const insertPos = firstElMatch.index!
643
+ return html.slice(0, insertPos) + html.slice(insertPos).replace(TAG_HEAD_PATTERN, `$1${attrs}`)
394
644
  }
395
645
 
396
646
  /**
@@ -425,20 +675,38 @@ export function renderChild(
425
675
  // `integrations/shared/e2e/toggle.spec.ts` asserts. The def carries the
426
676
  // plain name for exactly this — see `ComponentDef.name`, "Used for scope
427
677
  // ID generation" (#2518).
428
- const displayName = getRegisteredDef(name)?.name ?? name
678
+ const def = getRegisteredDef(name)
679
+ const displayName = def?.name ?? name
680
+ // A genuine fragment-root child (#2722) carries NO `bf-s`/`bf-h`/`bf-m`
681
+ // element attributes at all — SSR moves them into a wrapping
682
+ // `<!--bf-scope:-->` comment instead (`wrapWithScopeComment`, hono-
683
+ // adapter.ts). Below, `isFragmentRoot` skips the attribute-splicing path
684
+ // entirely and wraps the child's markup in the same comment shape —
685
+ // otherwise every fragment-root child rendered inline by a PARENT's own
686
+ // template (as opposed to a fresh top-level `createComponent()` mount,
687
+ // `materializeComponent`'s equivalent fix) kept stamping `bf-s` onto an
688
+ // element the SSR/hydrate reference never puts one on.
689
+ const isFragmentRoot = def?.fragmentRoot === true
429
690
  const scopePrefix = (_parentScopeId && slotSuffix)
430
691
  ? _parentScopeId
431
692
  : `${displayName}_${generateId()}`
693
+ const scopeId = `${scopePrefix}${suffix}`
432
694
  const keyAttr = key !== undefined ? ` ${BF_KEY}="${key}"` : ''
433
695
  // Slot-relationship markers — only emitted when both host and slot are
434
696
  // known; top-level renders without parent context omit them.
435
697
  const slotAttrs = (_parentScopeId && slotSuffix)
436
698
  ? ` ${BF_HOST}="${_parentScopeId}" ${BF_AT}="${slotSuffix}"`
437
699
  : ''
438
- const bfsAttr = `${BF_SCOPE}="${scopePrefix}${suffix}"`
700
+ const bfsAttr = `${BF_SCOPE}="${scopeId}"`
439
701
 
440
702
  if (!templateFn) {
441
- return `<div ${bfsAttr}${slotAttrs}${keyAttr}></div>`
703
+ // No template registered: same empty-shell fallback either way, but a
704
+ // fragment-root child still gets its comment pair instead of `bf-s` —
705
+ // an empty `<div></div>` with no scope marker at all would be
706
+ // unfindable by any later `$c()` lookup.
707
+ return isFragmentRoot
708
+ ? `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}--><div></div><!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
709
+ : `<div ${bfsAttr}${slotAttrs}${keyAttr}></div>`
442
710
  }
443
711
 
444
712
  // Push `_parentScopeId` to THIS child's own derived scope while its
@@ -469,9 +737,29 @@ export function renderChild(
469
737
  PLACEHOLDER_ATTR_PATTERN,
470
738
  _parentScopeId ? ` bf-s="${_parentScopeId}"` : '',
471
739
  )
740
+
741
+ // Fragment-root child (#2722): wrap the whole rendered markup in the
742
+ // SSR/hydrate boundary-comment shape instead of splicing `bf-s`/`bf-h`/
743
+ // `bf-m` into a first element that, structurally, owns none of them —
744
+ // `wrapWithScopeComment`'s CSR mirror, same as `materializeComponent`'s
745
+ // fix for a top-level mount.
746
+ if (isFragmentRoot) {
747
+ const hostSuffix = (_parentScopeId && slotSuffix) ? `|h=${_parentScopeId}|m=${slotSuffix}` : ''
748
+ // #2732: `data-key` for a fragment-root loop row lands on the row's own
749
+ // first element — the same "first element, not first node" convention
750
+ // `IRElement.keyAttr` uses on the SSR side (jsx-to-ir.ts) — not on the
751
+ // comment above, which carries scope identity only. This keeps
752
+ // `mapArray`'s existing `primaryEl.dataset.key` read (map-array.ts)
753
+ // working unchanged for markup this function pre-builds (the pure-CSR
754
+ // `materializeComponent` template-string path, used when there is no
755
+ // SSR content to hydrate against).
756
+ const keyedHtml = keyAttr ? spliceAttrsAfterFirstTag(html, keyAttr) : html
757
+ return `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}${hostSuffix}-->${keyedHtml}<!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
758
+ }
759
+
472
760
  // Templates may start with comment markers (e.g. <!--bf-cond-start:...-->)
473
761
  // so we find the first element tag rather than assuming index 0.
474
- const firstElMatch = html.match(/<(\w+)/)
762
+ const firstElMatch = html.match(FIRST_TAG_PATTERN)
475
763
  if (!firstElMatch) return html
476
764
  const insertPos = firstElMatch.index!
477
765
  // Dedupe `bf-s` only when the template body's root already carries
@@ -484,10 +772,10 @@ export function renderChild(
484
772
  if (ROOT_HAS_BFS_PATTERN.test(afterInsert)) {
485
773
  if (!extraAttrs) return html
486
774
  return html.slice(0, insertPos) +
487
- afterInsert.replace(/^(<\w+)/, `$1${extraAttrs}`)
775
+ afterInsert.replace(TAG_HEAD_PATTERN, `$1${extraAttrs}`)
488
776
  }
489
777
  return html.slice(0, insertPos) +
490
- afterInsert.replace(/^(<\w+)/, `$1 ${bfsAttr}${extraAttrs}`)
778
+ afterInsert.replace(TAG_HEAD_PATTERN, `$1 ${bfsAttr}${extraAttrs}`)
491
779
  }
492
780
 
493
781
  // The leading `\s+` is part of the match so dropping the attribute
@@ -506,11 +794,11 @@ function generateId(): string {
506
794
  /**
507
795
  * Create a placeholder element when template is not found
508
796
  */
509
- function createPlaceholder(name: string, key?: string | number): HTMLElement {
797
+ function createPlaceholder(name: string, key?: string | number, keyAttrName: string = BF_KEY): HTMLElement {
510
798
  const el = document.createElement('div')
511
799
  el.setAttribute(BF_SCOPE, `${name}_placeholder`)
512
800
  if (key !== undefined) {
513
- el.setAttribute(BF_KEY, String(key))
801
+ el.setAttribute(keyAttrName, String(key))
514
802
  }
515
803
  el.textContent = `[${name}]`
516
804
  el.style.cssText = 'color: red; border: 1px dashed red; padding: 4px;'
@@ -686,6 +974,34 @@ export function escapeTextOrMarkup(value: unknown): string {
686
974
  return escapeText(value)
687
975
  }
688
976
 
977
+ /**
978
+ * Nullish guard for a bare `${children}` passthrough splice
979
+ * (`ir-to-client-js/html-template.ts`'s no-`slotId` `'expression'` branches
980
+ * — the "bare `${...}` interpolations" the docstring above `escapeTextSlotExpr`
981
+ * describes) — #2775. The value here is ALREADY-STRINGIFIED MARKUP, not an
982
+ * arbitrary prop: `materializeComponent` (this file, "Template functions
983
+ * expect children as an HTML string, not an array") joins a component's
984
+ * `children` into an HTML string before the template lambda ever runs, so by
985
+ * the time this function sees the value it is either that HTML string or,
986
+ * when the caller passed none, simply `undefined` (the key is absent from
987
+ * the props object entirely). This function's ENTIRE job is that one
988
+ * nullish case: turn the absent/`null` value into `''`, same as SSR's
989
+ * `{props.children}` and the DSL adapters' `bf.string(children)` render it.
990
+ *
991
+ * Deliberately NOT `escapeTextOrMarkup`: that function falls back to
992
+ * `escapeText` for anything not `bfMarkup()`-branded, and a `children`
993
+ * string is never branded (it is a plain string built by
994
+ * `materializeComponent`, not compiler-escaped piece-by-piece the way a
995
+ * `bfMarkup()` value is) — routing `children` through it would HTML-escape
996
+ * real markup into visible `&lt;span&gt;`-style text on every call that
997
+ * actually has children, trading the no-children bug for a strictly worse
998
+ * with-children one. So: nullish becomes `''`; every other value is
999
+ * returned completely unescaped, exactly as it arrived.
1000
+ */
1001
+ export function markupOrEmpty(value: unknown): string {
1002
+ return value == null ? '' : (value as string)
1003
+ }
1004
+
689
1005
  /**
690
1006
  * `escapeText`'s counterpart for a claimed 'markup' slot's REACTIVE write
691
1007
  * (slot unification A3 follow-up), where the value is a plain-JS expression
@@ -810,6 +1126,7 @@ function createComponentFromDef(
810
1126
  key?: string | number,
811
1127
  mountAt?: Element | null,
812
1128
  rowMount?: { container: Node; anchor: Node | null } | null,
1129
+ keyAttrName: string = BF_KEY,
813
1130
  ): HTMLElement {
814
1131
  if (!def.template) {
815
1132
  throw new Error('[BarefootJS] createComponent with ComponentDef requires a template function')
@@ -834,7 +1151,7 @@ function createComponentFromDef(
834
1151
  const scopeId = `${name}_${generateId()}`
835
1152
  element.setAttribute(BF_SCOPE, scopeId)
836
1153
  if (key !== undefined) {
837
- element.setAttribute(BF_KEY, String(key))
1154
+ element.setAttribute(keyAttrName, String(key))
838
1155
  }
839
1156
 
840
1157
  // Connect before init, for the same reason the registry path does (see
@@ -422,8 +422,17 @@ function hydrateCommentScope(comment: Comment): void {
422
422
 
423
423
  commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId })
424
424
 
425
- const parsed = parseProps(propsJson || null, `comment scope ${scopeId}`)
426
- const props = (parsed[name] ?? {}) as Record<string, unknown>
425
+ // The JSON is the scope's OWN flat props object, not namespaced under
426
+ // the component name `wrapWithScopeComment` (hono-adapter.ts) emits
427
+ // `__bfPropsJson` verbatim after the `|`, exactly like `hydrateElementScope`
428
+ // reads `bf-p` directly below with no unwrap step. An earlier `parsed[name]
429
+ // ?? {}` here assumed a `{ [name]: props }` shape that no emitter ever
430
+ // produces, so every root fragment scope with props hydrated against an
431
+ // empty object (#2721) — invisible whenever `currentComponentHasProps` was
432
+ // false (no props segment emitted at all, so `{}` was already correct),
433
+ // which is why this survived until the mutation sweep's fragment-wrap
434
+ // exercised a component that actually depends on its props at hydration.
435
+ const props = parseProps(propsJson || null, `comment scope ${scopeId}`)
427
436
  runInit(proxyEl, def, props)
428
437
  }
429
438
 
@@ -112,6 +112,7 @@ export {
112
112
  bfMarkup,
113
113
  isBfMarkup,
114
114
  escapeTextOrMarkup,
115
+ markupOrEmpty,
115
116
  type BfMarkup,
116
117
  } from './component.ts'
117
118
 
@@ -166,6 +166,11 @@ export interface LazyRowPlan<T> {
166
166
  * @param plan - Compiler-emitted row plan (see {@link LazyRowPlan})
167
167
  * @param markerId - Scoped loop marker id (`<!--bf-loop:<id>-->`), see #1087
168
168
  * @param bfId - Profiler attribution id for the reconciler effect
169
+ * @param keyAttrName - The row-key attribute NAME this loop resolved at
170
+ * compile time (`IRElement.keyAttr`/`keyAttrName(loop.depth)`,
171
+ * jsx-to-ir.ts). Defaults to `BF_KEY` (plain `'data-key'`),
172
+ * correct at the outermost loop; only a nested loop's
173
+ * compiled call passes a depth-suffixed name (#2753).
169
174
  */
170
175
  export function mapArrayLazy<T>(
171
176
  accessor: () => T[],
@@ -174,6 +179,7 @@ export function mapArrayLazy<T>(
174
179
  plan: LazyRowPlan<T>,
175
180
  markerId?: string,
176
181
  bfId?: string,
182
+ keyAttrName: string = BF_KEY,
177
183
  ): void {
178
184
  if (!container) return
179
185
 
@@ -249,8 +255,11 @@ export function mapArrayLazy<T>(
249
255
  * so it is wrapped in `untrack()`: it writes outer-involving bindings
250
256
  * with current values (contract), and those signal reads must not
251
257
  * subscribe the reconciler. The returned element is assigned to
252
- * `entry.primaryEl`; `data-key` is stamped if `createRow` didn't
253
- * (mirrors `mapArray`'s create path).
258
+ * `entry.primaryEl`; the row's key attribute is stamped if `createRow`
259
+ * didn't (mirrors `mapArray`'s create path) — only for a KEYED loop
260
+ * (#2753 Shape A: an unkeyed loop's rows carry no key attribute at all),
261
+ * using `keyAttrName` (Shape B: a nested loop's name is depth-suffixed,
262
+ * never the plain default `BF_KEY`).
254
263
  */
255
264
  const createEntry = (item: T, index: number, key: string): LazyRowEntry<T> => {
256
265
  const entry: LazyRowEntry<T> = {
@@ -263,7 +272,7 @@ export function mapArrayLazy<T>(
263
272
  last: null,
264
273
  }
265
274
  entry.primaryEl = untrack(() => plan.createRow(entry, index))
266
- if (!entry.primaryEl.dataset.key) entry.primaryEl.setAttribute(BF_KEY, key)
275
+ if (getKey && !entry.primaryEl.getAttribute(keyAttrName)) entry.primaryEl.setAttribute(keyAttrName, key)
267
276
  markStranded()
268
277
  return entry
269
278
  }
@@ -294,7 +303,9 @@ export function mapArrayLazy<T>(
294
303
  const el = doms[i]
295
304
  // READ the SSR-rendered key (never write it on adopted rows);
296
305
  // positional item pairing is sound by the §9.3(2) eligibility gate.
297
- const ssrKey = el.getAttribute(BF_KEY)
306
+ // `keyAttrName`, not the plain `BF_KEY` default — a nested loop's
307
+ // rows carry a depth-suffixed name (#2753 Shape B).
308
+ const ssrKey = el.getAttribute(keyAttrName)
298
309
  const key = ssrKey !== null ? ssrKey : getKey ? getKey(items[i], i) : String(i)
299
310
  const entry: LazyRowEntry<T> = { key, primaryEl: el, item: items[i], refs: null, last: null }
300
311
  entries.set(key, entry)