@barefootjs/client 0.33.1 → 0.33.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.
@@ -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,13 +147,52 @@ 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
+ ): HTMLElement
182
+ export function createComponent(
183
+ nameOrDef: string | ComponentDef,
184
+ props?: Record<string, unknown>,
185
+ key?: string | number,
186
+ slot?: CreateComponentSlotInfo,
187
+ mountAt?: Element | null,
188
+ ): HTMLElement | DocumentFragment
140
189
  export function createComponent(
141
190
  nameOrDef: string | ComponentDef,
142
191
  props: Record<string, unknown> = {},
143
192
  key?: string | number,
144
193
  slot?: CreateComponentSlotInfo,
145
194
  mountAt?: Element | null,
146
- ): HTMLElement {
195
+ ): HTMLElement | DocumentFragment {
147
196
  const element = materializeComponent(nameOrDef, props, key, slot, mountAt)
148
197
  // `mountAt` is an unconditional obligation: callers used to run
149
198
  // `ph.replaceWith(comp)` themselves on every outcome, so every path that
@@ -152,7 +201,9 @@ export function createComponent(
152
201
  // which must stay detached so its self-replacement stays recoverable.
153
202
  // `parentNode` (not `isConnected`) is the right "still unconsumed" probe: it
154
203
  // survives a `mountAt` that was itself detached, which is the normal case
155
- // during multi-root loop-body setup.
204
+ // during multi-root loop-body setup. A fragment-root's `DocumentFragment`
205
+ // return only ever happens when `mountAt` is absent (see above), so it
206
+ // never reaches this branch.
156
207
  if (mountAt && mountAt.parentNode && element !== mountAt) {
157
208
  mountAt.replaceWith(element)
158
209
  }
@@ -173,7 +224,7 @@ function materializeComponent(
173
224
  key?: string | number,
174
225
  slot?: CreateComponentSlotInfo,
175
226
  mountAt?: Element | null,
176
- ): HTMLElement {
227
+ ): HTMLElement | DocumentFragment {
177
228
  // A bare callable shim invoked from user code (e.g. an object-literal
178
229
  // value `LOGOS[id]()` whose arrow the compiler hoisted into a component)
179
230
  // reaches us with no props (#1663). Normalize to an empty object so the
@@ -230,11 +281,22 @@ function materializeComponent(
230
281
 
231
282
  // 4. Pre-generate the component's scope ID.
232
283
  //
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.
284
+ // `comment: true` components are proxy-scoped — no element of their own
285
+ // carries `bf-s` directly but that covers TWO different shapes
286
+ // (`ComponentDef.fragmentRoot`'s docstring, types.ts) that need OPPOSITE
287
+ // treatment here:
288
+ // - root-is-a-child-call (#1211/#2649, `fragmentRoot` false): the
289
+ // parsed `firstChild` IS the child's own already-scoped element.
290
+ // Don't overwrite it (scopeId stays null), or `$c(__scope, 's0')`
291
+ // from the wrapper's init resolves to null.
292
+ // - genuine fragment root (`fragmentRoot` true): the parsed `firstChild`
293
+ // carries NO scope of its own (SSR moves it into the wrapping
294
+ // `<!--bf-scope:-->` comment) — generate one just the same, so
295
+ // `_parentScopeId` below still gets threaded into nested
296
+ // `renderChild()` calls and their naming matches SSR/hydrate (#2722:
297
+ // leaving this null made every nested child fall back to a random,
298
+ // un-prefixed scope id — `Select_xyz` instead of the expected
299
+ // `SelectBasicDemo_xyz_s8`).
238
300
  //
239
301
  // `slot` is only supplied by `upsertChild` / `upsertChildItem` mounting a
240
302
  // component nested below a loop row root — the SSR reference (Hono)
@@ -245,11 +307,12 @@ function materializeComponent(
245
307
  // random id, matching the reference behaviour.
246
308
  const def = getRegisteredDef(name)
247
309
  const isCommentWrapper = def?.comment === true
310
+ const isFragmentRoot = def?.fragmentRoot === true
248
311
  const derivedScopeId = slot?.parent && slot.mount ? `${slot.parent}_${slot.mount}` : null
249
312
  // Same as in `renderChild`: `name` is the registry key, which is
250
313
  // file-scoped (`Name__<8hex>`) for a non-exported component. The scope ID
251
314
  // must carry the plain name — see `ComponentDef.name` (#2518).
252
- const scopeId = isCommentWrapper ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
315
+ const scopeId = (isCommentWrapper && !isFragmentRoot) ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
253
316
 
254
317
  // 5. Generate HTML from props.
255
318
  //
@@ -278,8 +341,52 @@ function materializeComponent(
278
341
  _parentScopeId = prevParentScopeId
279
342
  }
280
343
 
281
- // 6. Create DOM element
282
- const element = parseHTML(html.trim()).firstChild as HTMLElement
344
+ // 6. Create DOM node(s).
345
+ //
346
+ // A genuine fragment root's template concatenates EVERY top-level
347
+ // sibling into one HTML string, so `roots` is the whole ordered list —
348
+ // `parseHTML(...).firstChild` used to be the only node kept, silently
349
+ // dropping the rest (#2735). Everything travels, whatever its node
350
+ // type: a fragment's top level is not only elements. Bare text between
351
+ // two element roots (`<><h1/>text<p/></>`) is a root, and a reactive
352
+ // text slot sitting there renders as a `<!--bf:sN-->` marker. Both were
353
+ // measured being dropped by an element-only walk — the text as a
354
+ // visible SSR/CSR-mount diff, the marker as something worse, since the
355
+ // runtime's own slot lookup then finds nothing to bind.
356
+ //
357
+ // `element` is the PROXY: the one node threaded through init /
358
+ // `commentScopeRegistry` / the return value. It must be an Element —
359
+ // everything downstream calls `setAttribute`/`hasAttribute` on it — so
360
+ // it is the first ELEMENT among the roots, not simply the first node.
361
+ // `<>text<p/></>` puts a Text node first, and taking that as the proxy
362
+ // threw `element.hasAttribute is not a function` at step 7b (measured;
363
+ // pre-dates #2735's fix, which is why the roots list and the proxy are
364
+ // chosen separately rather than the proxy being `roots[0]`).
365
+ //
366
+ // Only `isFragmentRoot` templates can emit more than one top-level node
367
+ // (jsx-to-ir.ts's `transformFragment`), so every other shape keeps
368
+ // exactly the single-node list it always had.
369
+ const parsedFragment = parseHTML(html.trim())
370
+ const roots: Node[] = isFragmentRoot
371
+ ? Array.from(parsedFragment.childNodes)
372
+ : parsedFragment.firstChild
373
+ ? [parsedFragment.firstChild]
374
+ : []
375
+ const element = (isFragmentRoot
376
+ ? roots.find(node => node.nodeType === Node.ELEMENT_NODE)
377
+ : roots[0]) as HTMLElement | undefined
378
+
379
+ // A fragment root with no element at all (`<>just text</>`) has nothing
380
+ // that can carry a scope. Refuse it the same way an empty template is
381
+ // refused rather than crashing on the first `setAttribute` — loud, not
382
+ // silent, per the sound-or-loud rule.
383
+ if (isFragmentRoot && roots.length > 0 && !element) {
384
+ console.warn(
385
+ `[BarefootJS] Fragment-root component ${name} rendered no element root; ` +
386
+ 'a scope needs at least one element to attach to. Wrap the content in an element.',
387
+ )
388
+ return createPlaceholder(name, key)
389
+ }
283
390
 
284
391
  if (!element) {
285
392
  console.warn(`[BarefootJS] Template returned empty HTML for component: ${name}`)
@@ -287,7 +394,13 @@ function materializeComponent(
287
394
  }
288
395
 
289
396
  // 7. Set scope ID and key attributes.
290
- if (scopeId) {
397
+ //
398
+ // A genuine fragment root carries its scope id on a WRAPPING comment
399
+ // pair, never as a `bf-s` attribute on the element itself — matching
400
+ // `wrapWithScopeComment` (hono-adapter.ts) and `hydrateCommentScope`
401
+ // (hydrate.ts). `scopeId` is still non-null for this shape (step 4) so
402
+ // `_parentScopeId` threads correctly; only the ATTRIBUTE is skipped here.
403
+ if (scopeId && !isFragmentRoot) {
291
404
  element.setAttribute(BF_SCOPE, scopeId)
292
405
  }
293
406
  if (slot) {
@@ -298,6 +411,27 @@ function materializeComponent(
298
411
  element.setAttribute(BF_KEY, String(key))
299
412
  }
300
413
 
414
+ // 7a. Fragment-root boundary comments + registry (#2722).
415
+ //
416
+ // `find()`/`$()`/`$c()` (query.ts) resolve a slot or child scope by
417
+ // walking `commentScopeRegistry`'s stored comment and its boundary
418
+ // (`getCommentScopeBoundary`, scope.ts) — that walk needs REAL, sibling-
419
+ // connected comment nodes, not just a registry entry, or `find()`'s
420
+ // comment-scope branch enumerates zero candidates (worse than the
421
+ // fallback `querySelectorAll` path a non-fragment scope gets). So these
422
+ // are built now and threaded through to wherever `element` ends up
423
+ // connected below, exactly mirroring the SSR/hydrate shape:
424
+ // <!--bf-scope:ID-->` + element + `<!--bf-/scope:ID-->`
425
+ const fragmentComments = isFragmentRoot && scopeId
426
+ ? {
427
+ start: document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`),
428
+ end: document.createComment(`${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}`),
429
+ }
430
+ : null
431
+ if (fragmentComments) {
432
+ commentScopeRegistry.set(element, { commentNode: fragmentComments.start, scopeId: scopeId! })
433
+ }
434
+
301
435
  // 7b. Connect before init.
302
436
  //
303
437
  // `initFn` resolves context by DOM position (`useContext` walks
@@ -317,14 +451,76 @@ function materializeComponent(
317
451
  // live DOM with no handle on the result, so this shape keeps the
318
452
  // detached behaviour.
319
453
  const rootIsDeferredPlaceholder = element.hasAttribute(BF_PLACEHOLDER)
454
+ // A fragment root's boundary comments must land adjacent to `element`
455
+ // at the SAME moment it connects, in each of the three shapes below —
456
+ // there is no later hook to attach them once the caller has taken the
457
+ // return value away (see `createComponent`'s docstring for the fourth,
458
+ // no-known-destination shape, handled after `init` runs).
459
+ let bareFragment: DocumentFragment | null = null
320
460
  if (mountAt && !rootIsDeferredPlaceholder) {
321
- mountAt.replaceWith(element)
461
+ if (fragmentComments) {
462
+ mountAt.replaceWith(fragmentComments.start, ...roots, fragmentComments.end)
463
+ } else {
464
+ mountAt.replaceWith(element)
465
+ }
322
466
  } else if (rowMount && !rootIsDeferredPlaceholder) {
323
467
  // Loop row: no placeholder exists, so connect at the position `mapArray`
324
468
  // handed down. The reorder step may move the row afterwards; any position
325
469
  // inside the container yields the same ancestor chain, which is all
326
470
  // `useContext`'s parentElement walk needs.
327
- rowMount.container.insertBefore(element, rowMount.anchor)
471
+ if (fragmentComments) {
472
+ rowMount.container.insertBefore(fragmentComments.start, rowMount.anchor)
473
+ rowMount.container.insertBefore(element, rowMount.anchor)
474
+ rowMount.container.insertBefore(fragmentComments.end, rowMount.anchor)
475
+ // Hand the boundary pair to `mapArray`'s row bookkeeping (map-array.ts's
476
+ // `ItemScope.scopeComments`, #2733) via the same stash-on-the-element
477
+ // convention `__bfExtras` uses for a multi-root loop BODY's extra
478
+ // siblings: `createItemScope` reads and deletes this property right
479
+ // after `renderItem` returns, since there is no other channel back to
480
+ // the caller once `element` is the only thing returned below. Without
481
+ // this, a later reorder/removal of the row moves/removes `element`
482
+ // and leaves the comments behind, orphaned in the container.
483
+ ;(element as unknown as { __bfScopeComments?: { start: Comment; end: Comment } }).__bfScopeComments =
484
+ fragmentComments
485
+ // Deliberately NOT inserting the other roots here (a fragment-root
486
+ // component whose OWN render has 2+ top-level nodes, used as a loop
487
+ // row) — connecting them is a separate gap from the boundary-comment
488
+ // tracking #2733 fixed above: even with `ItemScope` now able to carry
489
+ // the row's comments, there is still nowhere on `ItemScope` for a
490
+ // second or third top-level ELEMENT of the row itself (as opposed to
491
+ // `extras`, which is the multi-root loop BODY's own, unrelated,
492
+ // per-item marker convention). Not reachable by any currently tracked
493
+ // fixture (no fragment-root component with 2+ top-level nodes is used
494
+ // as a loop row in the mutation corpus), so declared rather than grown
495
+ // here:
496
+ // https://github.com/piconic-ai/barefootjs/issues/2733
497
+ //
498
+ // Loud, not silent: the whole point of the fix above is that
499
+ // dropping roots without saying so is the failure mode. A gap that
500
+ // stays quiet is indistinguishable from correctness at the call
501
+ // site, so the one shape still dropping them says so.
502
+ if (roots.length > 1) {
503
+ console.warn(
504
+ `[BarefootJS] Fragment-root component ${name} used as a loop row renders ` +
505
+ `${roots.length} top-level nodes; only the first element is connected here. ` +
506
+ 'See https://github.com/piconic-ai/barefootjs/issues/2733',
507
+ )
508
+ }
509
+ } else {
510
+ rowMount.container.insertBefore(element, rowMount.anchor)
511
+ }
512
+ } else if (fragmentComments && !rootIsDeferredPlaceholder) {
513
+ // Neither a placeholder nor an ambient row position: the caller owns
514
+ // connecting the result itself (e.g. the compiler's exported
515
+ // `export function Name(props, key) { return createComponent(...) }`
516
+ // shim, called directly with no further composition — the shape
517
+ // `fixture-host.ts`'s `'csr-mount'` boot script uses). Bundle every
518
+ // node in one `DocumentFragment` so a plain `container.append(result)`
519
+ // / `el.replaceWith(result)` moves all of them together —
520
+ // `createComponent`'s docstring covers why this is the one shape that
521
+ // can't return a bare `HTMLElement`.
522
+ bareFragment = document.createDocumentFragment()
523
+ bareFragment.append(fragmentComments.start, ...roots, fragmentComments.end)
328
524
  }
329
525
 
330
526
  // 8. Set currentScope so provideContext/useContext are element-scoped.
@@ -390,7 +586,36 @@ function materializeComponent(
390
586
  // 12. Mark element as initialized
391
587
  hydratedScopes.add(element)
392
588
 
393
- return element
589
+ // `bareFragment` bundles `element` with its boundary comments for the
590
+ // one shape with no known destination (7b) — everything else returns
591
+ // the real, single element unchanged.
592
+ return bareFragment ?? element
593
+ }
594
+
595
+ /**
596
+ * Splice `attrs` (e.g. ` data-key="1"`) onto `html`'s first element's own
597
+ * tag, wherever that tag starts (templates may open with comment markers
598
+ * like `<!--bf-cond-start:...-->` before the first real element). No-op —
599
+ * returns `html` unchanged — if no element tag is found at all.
600
+ */
601
+ /**
602
+ * A tag name is not `\\w+`: custom elements are required to contain a
603
+ * hyphen (`<my-widget>`), and `.` and `:` are legal too. Matching only
604
+ * `[A-Za-z0-9_]` spliced attributes into the MIDDLE of such a name
605
+ * (`<my bf-s="…"-widget>`), which the parser then drops entirely — while
606
+ * SSR, which places the same attributes as a compiler-emitted JSX spread,
607
+ * kept emitting them correctly. Anchored on a leading letter so the
608
+ * comment markers a template may open with (`<!--bf-cond-start:…-->`) are
609
+ * still skipped rather than matched.
610
+ */
611
+ const FIRST_TAG_PATTERN = /<([a-zA-Z][^\s/>]*)/
612
+ const TAG_HEAD_PATTERN = /^(<[a-zA-Z][^\s/>]*)/
613
+
614
+ function spliceAttrsAfterFirstTag(html: string, attrs: string): string {
615
+ const firstElMatch = html.match(FIRST_TAG_PATTERN)
616
+ if (!firstElMatch) return html
617
+ const insertPos = firstElMatch.index!
618
+ return html.slice(0, insertPos) + html.slice(insertPos).replace(TAG_HEAD_PATTERN, `$1${attrs}`)
394
619
  }
395
620
 
396
621
  /**
@@ -425,20 +650,38 @@ export function renderChild(
425
650
  // `integrations/shared/e2e/toggle.spec.ts` asserts. The def carries the
426
651
  // plain name for exactly this — see `ComponentDef.name`, "Used for scope
427
652
  // ID generation" (#2518).
428
- const displayName = getRegisteredDef(name)?.name ?? name
653
+ const def = getRegisteredDef(name)
654
+ const displayName = def?.name ?? name
655
+ // A genuine fragment-root child (#2722) carries NO `bf-s`/`bf-h`/`bf-m`
656
+ // element attributes at all — SSR moves them into a wrapping
657
+ // `<!--bf-scope:-->` comment instead (`wrapWithScopeComment`, hono-
658
+ // adapter.ts). Below, `isFragmentRoot` skips the attribute-splicing path
659
+ // entirely and wraps the child's markup in the same comment shape —
660
+ // otherwise every fragment-root child rendered inline by a PARENT's own
661
+ // template (as opposed to a fresh top-level `createComponent()` mount,
662
+ // `materializeComponent`'s equivalent fix) kept stamping `bf-s` onto an
663
+ // element the SSR/hydrate reference never puts one on.
664
+ const isFragmentRoot = def?.fragmentRoot === true
429
665
  const scopePrefix = (_parentScopeId && slotSuffix)
430
666
  ? _parentScopeId
431
667
  : `${displayName}_${generateId()}`
668
+ const scopeId = `${scopePrefix}${suffix}`
432
669
  const keyAttr = key !== undefined ? ` ${BF_KEY}="${key}"` : ''
433
670
  // Slot-relationship markers — only emitted when both host and slot are
434
671
  // known; top-level renders without parent context omit them.
435
672
  const slotAttrs = (_parentScopeId && slotSuffix)
436
673
  ? ` ${BF_HOST}="${_parentScopeId}" ${BF_AT}="${slotSuffix}"`
437
674
  : ''
438
- const bfsAttr = `${BF_SCOPE}="${scopePrefix}${suffix}"`
675
+ const bfsAttr = `${BF_SCOPE}="${scopeId}"`
439
676
 
440
677
  if (!templateFn) {
441
- return `<div ${bfsAttr}${slotAttrs}${keyAttr}></div>`
678
+ // No template registered: same empty-shell fallback either way, but a
679
+ // fragment-root child still gets its comment pair instead of `bf-s` —
680
+ // an empty `<div></div>` with no scope marker at all would be
681
+ // unfindable by any later `$c()` lookup.
682
+ return isFragmentRoot
683
+ ? `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}--><div></div><!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
684
+ : `<div ${bfsAttr}${slotAttrs}${keyAttr}></div>`
442
685
  }
443
686
 
444
687
  // Push `_parentScopeId` to THIS child's own derived scope while its
@@ -469,9 +712,29 @@ export function renderChild(
469
712
  PLACEHOLDER_ATTR_PATTERN,
470
713
  _parentScopeId ? ` bf-s="${_parentScopeId}"` : '',
471
714
  )
715
+
716
+ // Fragment-root child (#2722): wrap the whole rendered markup in the
717
+ // SSR/hydrate boundary-comment shape instead of splicing `bf-s`/`bf-h`/
718
+ // `bf-m` into a first element that, structurally, owns none of them —
719
+ // `wrapWithScopeComment`'s CSR mirror, same as `materializeComponent`'s
720
+ // fix for a top-level mount.
721
+ if (isFragmentRoot) {
722
+ const hostSuffix = (_parentScopeId && slotSuffix) ? `|h=${_parentScopeId}|m=${slotSuffix}` : ''
723
+ // #2732: `data-key` for a fragment-root loop row lands on the row's own
724
+ // first element — the same "first element, not first node" convention
725
+ // `IRElement.carriesDataKey` uses on the SSR side (jsx-to-ir.ts) — not
726
+ // on the comment above, which carries scope identity only. This keeps
727
+ // `mapArray`'s existing `primaryEl.dataset.key` read (map-array.ts)
728
+ // working unchanged for markup this function pre-builds (the pure-CSR
729
+ // `materializeComponent` template-string path, used when there is no
730
+ // SSR content to hydrate against).
731
+ const keyedHtml = keyAttr ? spliceAttrsAfterFirstTag(html, keyAttr) : html
732
+ return `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}${hostSuffix}-->${keyedHtml}<!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
733
+ }
734
+
472
735
  // Templates may start with comment markers (e.g. <!--bf-cond-start:...-->)
473
736
  // so we find the first element tag rather than assuming index 0.
474
- const firstElMatch = html.match(/<(\w+)/)
737
+ const firstElMatch = html.match(FIRST_TAG_PATTERN)
475
738
  if (!firstElMatch) return html
476
739
  const insertPos = firstElMatch.index!
477
740
  // Dedupe `bf-s` only when the template body's root already carries
@@ -484,10 +747,10 @@ export function renderChild(
484
747
  if (ROOT_HAS_BFS_PATTERN.test(afterInsert)) {
485
748
  if (!extraAttrs) return html
486
749
  return html.slice(0, insertPos) +
487
- afterInsert.replace(/^(<\w+)/, `$1${extraAttrs}`)
750
+ afterInsert.replace(TAG_HEAD_PATTERN, `$1${extraAttrs}`)
488
751
  }
489
752
  return html.slice(0, insertPos) +
490
- afterInsert.replace(/^(<\w+)/, `$1 ${bfsAttr}${extraAttrs}`)
753
+ afterInsert.replace(TAG_HEAD_PATTERN, `$1 ${bfsAttr}${extraAttrs}`)
491
754
  }
492
755
 
493
756
  // The leading `\s+` is part of the match so dropping the attribute
@@ -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