@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.
@@ -26,11 +26,38 @@ import {
26
26
  BF_LOOP_START,
27
27
  BF_LOOP_END,
28
28
  BF_LOOP_ITEM,
29
+ BF_SCOPE_COMMENT_PREFIX,
30
+ BF_SCOPE_COMMENT_END_PREFIX,
29
31
  loopStartMarker,
30
32
  loopEndMarker,
31
33
  loopItemMarker,
32
34
  } from '@barefootjs/shared'
33
35
 
36
+ /**
37
+ * A fragment-rooted component's own `<!--bf-scope:ID-->` /
38
+ * `<!--bf-/scope:ID-->` boundary pair (`wrapWithScopeComment`,
39
+ * hono-adapter.ts), captured when that component is used as a keyed loop
40
+ * row's `primaryEl` (#2733). Distinct from `ItemScope.startMarker` (the
41
+ * `<!--bf-loop-i-->` marker for a multi-root loop BODY): that marks a
42
+ * loop-body concept this module owns; this marks a component's own scope
43
+ * identity that the row's SSR/CSR emission owns and `commentScopeRegistry`
44
+ * (scope.ts) keys off. `insertScope`/`removeScope` carry it as part of the
45
+ * row's atomic unit so a reorder/removal doesn't orphan it.
46
+ */
47
+ type ScopeCommentPair = { start: Comment; end: Comment }
48
+
49
+ /**
50
+ * The scope id inside a `<!--bf-scope:ID|h=…|m=…|props-->` comment — the
51
+ * `|`-free head, matching `getCommentScopeBoundary` (scope.ts) and
52
+ * `parseCommentScopeId` (query.ts). Used to pair a start comment with its
53
+ * OWN end marker by id, never with a sibling scope's.
54
+ */
55
+ function scopeIdOf(start: Comment): string {
56
+ const rest = (start.nodeValue ?? '').slice(BF_SCOPE_COMMENT_PREFIX.length)
57
+ const pipe = rest.indexOf('|')
58
+ return pipe >= 0 ? rest.slice(0, pipe) : rest
59
+ }
60
+
34
61
  type ItemScope<T> = {
35
62
  /**
36
63
  * `<!--bf-loop-i-->` Comment that anchors a multi-root item. `null` for
@@ -49,6 +76,8 @@ type ItemScope<T> = {
49
76
  * two or more peers). Empty for single-root items.
50
77
  */
51
78
  extras: HTMLElement[]
79
+ /** See `ScopeCommentPair`. `null` when `primaryEl` isn't a fragment-root scope. */
80
+ scopeComments: ScopeCommentPair | null
52
81
  dispose: () => void
53
82
  setItem: (v: T) => void
54
83
  }
@@ -111,46 +140,103 @@ export function findLoopMarkers(
111
140
  * are present (single-root loops, the common case), each Element forms
112
141
  * its own range with `startMarker: null` and `extras: []` — preserving
113
142
  * legacy behavior verbatim.
143
+ *
144
+ * Also recognizes a fragment-rooted component's own `<!--bf-scope:ID-->` /
145
+ * `<!--bf-/scope:ID-->` pair immediately bracketing an item's `primaryEl`
146
+ * (#2733) and captures it as `scopeComments`, so the row's caller can pass
147
+ * it through to `createItemScope` and keep it moving with the row.
148
+ *
149
+ * A `|h=` child segment does NOT disqualify a comment here, though
150
+ * `hydrate.ts::hydrateCommentScope` skips exactly those. The two ask
151
+ * different questions. That walker runs at the top level, where a `|h=`
152
+ * comment means "some parent's `initChild` owns this scope, not me." This
153
+ * one runs BETWEEN a loop's own markers, where every row IS a child: real
154
+ * SSR emits each row as `<!--bf-scope:TodoRow_x|h=<host>|m=<slot>|<props>-->`
155
+ * (measured), so requiring a root-shaped comment matched nothing a server
156
+ * ever produces and left the pair behind on every reorder — the exact
157
+ * orphaning this field exists to prevent.
158
+ *
159
+ * The end comment is matched by scope ID rather than by "the next
160
+ * `bf-/scope:` seen", so a sibling root that is itself a fragment-rooted
161
+ * child cannot close this row's pair early.
114
162
  */
115
163
  function findItemRanges(start: Comment, end: Comment): Array<{
116
164
  startMarker: Comment | null
117
165
  primaryEl: HTMLElement
118
166
  extras: HTMLElement[]
167
+ scopeComments: ScopeCommentPair | null
119
168
  }> {
169
+ type PendingPair = { start: Comment; end: Comment | null }
120
170
  const ranges: Array<{
121
171
  startMarker: Comment | null
122
172
  primaryEl: HTMLElement | null
123
173
  extras: HTMLElement[]
174
+ scopeComments: PendingPair | null
124
175
  }> = []
125
- let current: { startMarker: Comment | null; primaryEl: HTMLElement | null; extras: HTMLElement[] } | null = null
176
+ let current: (typeof ranges)[number] | null = null
126
177
  let sawItemMarker = false
178
+ // A fragment-root row's own start comment, seen but not yet matched to
179
+ // the element it brackets — consumed the instant the next Element is seen.
180
+ let pendingScopeStart: Comment | null = null
181
+ // The pair still awaiting its end comment, so a later `bf-/scope:` knows
182
+ // which range to close — there is at most one open at a time since a row's
183
+ // own scope comment never nests another row's.
184
+ let openScopeComments: PendingPair | null = null
127
185
  let node: Node | null = start.nextSibling
128
186
  while (node && node !== end) {
129
- if (node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === BF_LOOP_ITEM) {
130
- sawItemMarker = true
131
- current = { startMarker: node as Comment, primaryEl: null, extras: [] }
132
- ranges.push(current)
187
+ if (node.nodeType === Node.COMMENT_NODE) {
188
+ const value = (node as Comment).nodeValue ?? ''
189
+ if (value === BF_LOOP_ITEM) {
190
+ sawItemMarker = true
191
+ current = { startMarker: node as Comment, primaryEl: null, extras: [], scopeComments: null }
192
+ ranges.push(current)
193
+ } else if (value.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
194
+ pendingScopeStart = node as Comment
195
+ } else if (
196
+ openScopeComments &&
197
+ value === BF_SCOPE_COMMENT_END_PREFIX + scopeIdOf(openScopeComments.start)
198
+ ) {
199
+ openScopeComments.end = node as Comment
200
+ openScopeComments = null
201
+ }
133
202
  } else if (node.nodeType === Node.ELEMENT_NODE) {
134
203
  const el = node as HTMLElement
204
+ let scopeComments: PendingPair | null = null
205
+ if (pendingScopeStart) {
206
+ scopeComments = { start: pendingScopeStart, end: null }
207
+ openScopeComments = scopeComments
208
+ pendingScopeStart = null
209
+ }
135
210
  if (sawItemMarker) {
136
- if (!current!.primaryEl) current!.primaryEl = el
137
- else current!.extras.push(el)
211
+ if (!current!.primaryEl) {
212
+ current!.primaryEl = el
213
+ current!.scopeComments = scopeComments
214
+ } else current!.extras.push(el)
138
215
  } else {
139
- ranges.push({ startMarker: null, primaryEl: el, extras: [] })
216
+ ranges.push({ startMarker: null, primaryEl: el, extras: [], scopeComments })
140
217
  }
141
218
  }
142
219
  node = node.nextSibling
143
220
  }
144
- return ranges.filter(
145
- (r): r is { startMarker: Comment | null; primaryEl: HTMLElement; extras: HTMLElement[] } =>
146
- r.primaryEl !== null,
147
- )
221
+ return ranges
222
+ .filter(
223
+ (r): r is { startMarker: Comment | null; primaryEl: HTMLElement; extras: HTMLElement[]; scopeComments: PendingPair | null } =>
224
+ r.primaryEl !== null,
225
+ )
226
+ .map(r => ({
227
+ ...r,
228
+ // Drop a scope-comment pair whose end was never found (malformed/
229
+ // truncated DOM) rather than carry a half-formed pair forward — the
230
+ // row still hydrates correctly, it just won't track the boundary.
231
+ scopeComments: r.scopeComments?.end ? (r.scopeComments as ScopeCommentPair) : null,
232
+ }))
148
233
  }
149
234
 
150
235
  /**
151
236
  * Insert a scope's nodes into `target` in their canonical order
152
- * (startMarker → primaryEl → extras). Idempotent — `insertBefore` on a
153
- * node already at the target position is a no-op.
237
+ * (startMarker → scopeComments.start → primaryEl → scopeComments.end
238
+ * extras). Idempotent — `insertBefore` on a node already at the target
239
+ * position is a no-op.
154
240
  *
155
241
  * `target` is typed as `Node` (not `HTMLElement`) so callers can pass a
156
242
  * `DocumentFragment` to batch several scopes into one subsequent
@@ -159,7 +245,9 @@ function findItemRanges(start: Comment, end: Comment): Array<{
159
245
  */
160
246
  function insertScope<T>(scope: ItemScope<T>, target: Node, anchor: Node | null): void {
161
247
  if (scope.startMarker) target.insertBefore(scope.startMarker, anchor)
248
+ if (scope.scopeComments) target.insertBefore(scope.scopeComments.start, anchor)
162
249
  target.insertBefore(scope.primaryEl, anchor)
250
+ if (scope.scopeComments) target.insertBefore(scope.scopeComments.end, anchor)
163
251
  for (const ex of scope.extras) target.insertBefore(ex, anchor)
164
252
  }
165
253
 
@@ -211,7 +299,9 @@ export function longestIncreasingSubsequenceIndices(arr: number[]): number[] {
211
299
  /** Detach all of a scope's nodes from the DOM. */
212
300
  function removeScope<T>(scope: ItemScope<T>): void {
213
301
  if (scope.startMarker?.parentNode) scope.startMarker.remove()
302
+ if (scope.scopeComments?.start.parentNode) scope.scopeComments.start.remove()
214
303
  if (scope.primaryEl.parentNode) scope.primaryEl.remove()
304
+ if (scope.scopeComments?.end.parentNode) scope.scopeComments.end.remove()
215
305
  for (const ex of scope.extras) {
216
306
  if (ex.parentNode) ex.remove()
217
307
  }
@@ -226,6 +316,13 @@ function removeScope<T>(scope: ItemScope<T>): void {
226
316
  * sibling roots on the returned element via a `__bfExtras` property that
227
317
  * we read-and-delete here. On hydration the caller passes `existingExtras`
228
318
  * + `existingStart` collected from the SSR partition.
319
+ *
320
+ * Fragment-root row handling (#2733): on CSR, `createComponent`
321
+ * (component.ts's `rowMount` branch) stashes the row's own
322
+ * `<!--bf-scope:ID-->` boundary pair on the returned element via a
323
+ * `__bfScopeComments` property, read-and-deleted here exactly like
324
+ * `__bfExtras`. On hydration the caller passes `existingScopeComments`
325
+ * collected from the SSR partition (`findItemRanges`).
229
326
  */
230
327
  function createItemScope<T>(
231
328
  item: T,
@@ -234,6 +331,7 @@ function createItemScope<T>(
234
331
  existingPrimary?: HTMLElement,
235
332
  existingExtras?: HTMLElement[],
236
333
  existingStart?: Comment | null,
334
+ existingScopeComments?: ScopeCommentPair | null,
237
335
  rowMount?: RowMountPoint | null,
238
336
  ): ItemScope<T> {
239
337
  let primaryEl!: HTMLElement
@@ -241,6 +339,7 @@ function createItemScope<T>(
241
339
  let setItem!: (v: T) => void
242
340
  let extras: HTMLElement[] = []
243
341
  let startMarker: Comment | null = null
342
+ let scopeComments: ScopeCommentPair | null = null
244
343
 
245
344
  createRoot((d) => {
246
345
  dispose = d
@@ -274,6 +373,7 @@ function createItemScope<T>(
274
373
  if (existingPrimary) {
275
374
  extras = existingExtras ?? []
276
375
  startMarker = existingStart ?? null
376
+ scopeComments = existingScopeComments ?? null
277
377
  } else {
278
378
  const stashed = (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
279
379
  if (stashed && stashed.length > 0) {
@@ -281,6 +381,11 @@ function createItemScope<T>(
281
381
  startMarker = document.createComment(BF_LOOP_ITEM)
282
382
  }
283
383
  delete (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
384
+
385
+ const stashedScopeComments = (primaryEl as unknown as { __bfScopeComments?: ScopeCommentPair })
386
+ .__bfScopeComments
387
+ if (stashedScopeComments) scopeComments = stashedScopeComments
388
+ delete (primaryEl as unknown as { __bfScopeComments?: ScopeCommentPair }).__bfScopeComments
284
389
  }
285
390
  return undefined
286
391
  })
@@ -294,7 +399,7 @@ function createItemScope<T>(
294
399
  primaryEl.remove()
295
400
  }
296
401
 
297
- return { startMarker, primaryEl, extras, dispose, setItem }
402
+ return { startMarker, primaryEl, extras, scopeComments, dispose, setItem }
298
403
  }
299
404
 
300
405
  /**
@@ -307,6 +412,16 @@ function createItemScope<T>(
307
412
  * Receives item as signal accessor: item() returns current value.
308
413
  * When `existing` is passed, initializes the SSR-rendered element and returns it.
309
414
  * When `existing` is undefined, creates a new element and returns it.
415
+ * @param keyAttrName - The row-key attribute NAME this loop resolved at
416
+ * compile time (`IRElement.keyAttr`/`keyAttrName(loop.depth)`,
417
+ * jsx-to-ir.ts) — `'data-key'` at the outermost loop,
418
+ * `'data-key-N'` N levels deep. Defaults to `BF_KEY`
419
+ * (plain `'data-key'`), correct for every depth-0 call
420
+ * site; only a nested loop's compiled call passes a
421
+ * depth-suffixed name (#2753 Shape B — see the "Why not
422
+ * stamp unconditionally" note below for why this
423
+ * replaced a hardcoded constant instead of just fixing
424
+ * the value).
310
425
  */
311
426
  export function mapArray<T>(
312
427
  accessor: () => T[],
@@ -315,6 +430,7 @@ export function mapArray<T>(
315
430
  renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
316
431
  markerId?: string,
317
432
  bfId?: string,
433
+ keyAttrName: string = BF_KEY,
318
434
  ): void {
319
435
  if (!container) return
320
436
 
@@ -352,21 +468,49 @@ export function mapArray<T>(
352
468
  const existingRanges = startMarker
353
469
  ? findItemRanges(startMarker, endMarker!)
354
470
  : Array.from(container.children).map(
355
- (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
471
+ (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[], scopeComments: null as ScopeCommentPair | null }),
356
472
  )
357
473
 
358
- // SSR elements need initialization when they haven't been adopted into scopes yet.
359
- // Check both: elements without data-key (legacy) OR elements with data-key but no scopes
360
- // (component loops render data-key in SSR template but haven't been hydrated).
474
+ // #2753 why not read the DOM for a hydration signal: this branch
475
+ // runs at most ONCE per `mapArray` call (guarded by the closure-local
476
+ // `hydrated` flag, set true on the line above, before anything else
477
+ // can run), and `scopes` is a closure-local Map that is ALWAYS empty
478
+ // the first time this line runs — nothing sets it earlier. So "has
479
+ // this container already been adopted" is already fully answered by
480
+ // `existingRanges.length > 0` (there is DOM here from SSR/a prior
481
+ // render) with no attribute read at all. The attribute the old check
482
+ // read (`hasAttribute('data-key')`) was never a sound signal anyway:
483
+ // an unkeyed loop's rows legitimately carry no key attribute at all
484
+ // (Shape A), and a nested loop's rows carry a depth-suffixed name
485
+ // (`data-key-N`, Shape B) — so the plain-name probe either
486
+ // misdiagnosed a correctly-unkeyed row as "not yet hydrated", or
487
+ // missed a correctly-keyed nested row entirely. `scopes.size === 0`
488
+ // is not a second, independent signal here — it is ALWAYS true at
489
+ // this line, so the two-part `||` reduced to the first probe being
490
+ // moot: this is a behavior-preserving simplification, not a new rule.
361
491
  const needsHydration = existingRanges.length > 0
362
- && (!existingRanges[0]?.primaryEl.hasAttribute('data-key') || scopes.size === 0)
363
492
  if (needsHydration) {
364
493
  // Hydrate in place: tag keys, create per-item scopes with renderItem(existing)
365
494
  for (let i = 0; i < existingRanges.length && i < items.length; i++) {
366
495
  const range = existingRanges[i]
367
496
  const item = items[i]
368
497
  const key = getKey ? getKey(item, i) : String(i)
369
- range.primaryEl.setAttribute(BF_KEY, key)
498
+ // Only a KEYED loop gets a key attribute at all (Shape A) — SSR
499
+ // (or the static template this row cloned from) already carries
500
+ // the correct, depth-aware name (Shape B) when it applies, so this
501
+ // is a defensive backfill for a row that arrived without one, not
502
+ // the primary writer. A FALSY-value check (`getAttribute`, not
503
+ // `hasAttribute`) — not just presence — because a compiled
504
+ // template can bake the attribute PRESENT but empty for a shape
505
+ // whose key expression isn't ready at clone time (measured on the
506
+ // csr-mount leg of a keyed top-level loop); `hasAttribute` would
507
+ // wrongly treat that empty placeholder as "already correct" and
508
+ // skip the backfill this line exists to make. Reads the SAME name
509
+ // being written (not `dataset.key`, which only ever reads the
510
+ // plain, undepth-suffixed name).
511
+ if (getKey && !range.primaryEl.getAttribute(keyAttrName)) {
512
+ range.primaryEl.setAttribute(keyAttrName, key)
513
+ }
370
514
 
371
515
  const scope = createItemScope(
372
516
  item,
@@ -375,6 +519,7 @@ export function mapArray<T>(
375
519
  range.primaryEl,
376
520
  range.extras,
377
521
  range.startMarker,
522
+ range.scopeComments,
378
523
  )
379
524
  scopes.set(key, scope)
380
525
  hydratedScopes.add(range.primaryEl)
@@ -386,8 +531,8 @@ export function mapArray<T>(
386
531
  const key = getKey ? getKey(item, i) : String(i)
387
532
  // Final position is known here (append before the loop's trailing
388
533
  // anchor), so the row can be mounted at it before its init runs.
389
- const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
390
- if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
534
+ const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, undefined, { container, anchor })
535
+ if (getKey && !scope.primaryEl.getAttribute(keyAttrName)) scope.primaryEl.setAttribute(keyAttrName, key)
391
536
  scopes.set(key, scope)
392
537
  insertScope(scope, container, anchor)
393
538
  }
@@ -396,7 +541,9 @@ export function mapArray<T>(
396
541
  for (let i = items.length; i < existingRanges.length; i++) {
397
542
  const range = existingRanges[i]
398
543
  if (range.startMarker?.parentNode) range.startMarker.remove()
544
+ if (range.scopeComments?.start.parentNode) range.scopeComments.start.remove()
399
545
  if (range.primaryEl.parentNode) range.primaryEl.remove()
546
+ if (range.scopeComments?.end.parentNode) range.scopeComments.end.remove()
400
547
  for (const ex of range.extras) {
401
548
  if (ex.parentNode) ex.remove()
402
549
  }
@@ -411,15 +558,19 @@ export function mapArray<T>(
411
558
  const loopRanges = startMarker
412
559
  ? findItemRanges(startMarker, endMarker!)
413
560
  : Array.from(container.children).map(
414
- (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
561
+ (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[], scopeComments: null as ScopeCommentPair | null }),
415
562
  )
416
563
  for (const range of loopRanges) {
417
- const existingKey = range.primaryEl.dataset?.key
564
+ // `getAttribute(keyAttrName)`, not `dataset.key` — `dataset` only
565
+ // ever reads the plain, undepth-suffixed `data-key`, which misses a
566
+ // nested loop's `data-key-N` rows entirely (#2753 Shape B).
567
+ const existingKey = range.primaryEl.getAttribute(keyAttrName)
418
568
  if (existingKey && !scopes.has(existingKey)) {
419
569
  scopes.set(existingKey, {
420
570
  startMarker: range.startMarker,
421
571
  primaryEl: range.primaryEl,
422
572
  extras: range.extras,
573
+ scopeComments: range.scopeComments,
423
574
  dispose: () => {},
424
575
  setItem: () => {},
425
576
  })
@@ -450,6 +601,7 @@ export function mapArray<T>(
450
601
  let expectedNodeCount = 0
451
602
  for (const scope of scopes.values()) {
452
603
  expectedNodeCount += 1 + scope.extras.length + (scope.startMarker ? 1 : 0)
604
+ + (scope.scopeComments ? 2 : 0)
453
605
  }
454
606
  let actualNodeCount = 0
455
607
  for (let node = container.firstChild; node; node = node.nextSibling) actualNodeCount++
@@ -501,8 +653,8 @@ export function mapArray<T>(
501
653
  // the loop range before its init runs; the LIS reorder below moves it
502
654
  // to its final position (it participates in the walk like any other
503
655
  // attached scope, so the resulting order is unchanged).
504
- const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
505
- if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
656
+ const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, undefined, { container, anchor })
657
+ if (getKey && !scope.primaryEl.getAttribute(keyAttrName)) scope.primaryEl.setAttribute(keyAttrName, key)
506
658
  scopes.set(key, scope)
507
659
  desiredOrder.push(scope)
508
660
  }
@@ -586,9 +738,14 @@ export function mapArray<T>(
586
738
  while (j < desiredOrder.length && !stationary[j]) j++
587
739
  // Insert this run immediately before the next stationary scope (which
588
740
  // is already exactly where it needs to be), or before the loop's
589
- // trailing anchor when the run reaches the end of the list.
741
+ // trailing anchor when the run reaches the end of the list. Preferring
742
+ // `scopeComments.start` over `primaryEl` here matters when that next
743
+ // scope is a fragment-root row (#2733): inserting directly before its
744
+ // `primaryEl` would land the new run BETWEEN its still-attached
745
+ // `<!--bf-scope:-->` start comment and its element, splitting a
746
+ // stationary scope's own boundary pair.
590
747
  const before = j < desiredOrder.length
591
- ? (desiredOrder[j].startMarker ?? desiredOrder[j].primaryEl)
748
+ ? (desiredOrder[j].startMarker ?? desiredOrder[j].scopeComments?.start ?? desiredOrder[j].primaryEl)
592
749
  : anchor
593
750
  if (j - i === 1) {
594
751
  insertScope(desiredOrder[i], container, before)
@@ -137,7 +137,9 @@ export function upsertChildItem(
137
137
  const ph = qsaItem(primaryEl, `[data-bf-ph="${phId}"]`) as HTMLElement | null
138
138
  if (ph) {
139
139
  const slot = slotId ? buildSlotInfo(primaryEl, slotId, anchorScope) : undefined
140
- // Connect before init — see the same call in `upsertChild`.
140
+ // Connect before init — see the same call in `upsertChild`. `mountAt`
141
+ // (`ph`) is always given here, so this lands on `createComponent`'s
142
+ // non-null-`mountAt` overload and is typed `HTMLElement`.
141
143
  return createComponent(name, props, key, slot, ph)
142
144
  }
143
145
  return null
@@ -161,7 +161,10 @@ export function upsertChild(
161
161
  // Hand the placeholder to `createComponent` so the new element is
162
162
  // connected before its init runs — `useContext` resolves by DOM
163
163
  // position, and a detached init silently fell back to the global
164
- // context store.
164
+ // context store. `mountAt` (`ph`) is always given here, so this lands
165
+ // on `createComponent`'s non-null-`mountAt` overload and is typed
166
+ // `HTMLElement` — the bare-fragment-root `DocumentFragment` shape is
167
+ // unreachable from a call that names its mount point.
165
168
  return createComponent(name, props, key, slot, ph)
166
169
  }
167
170
  return null
@@ -22,6 +22,24 @@ export interface ComponentDef {
22
22
  init: InitFn
23
23
  /** Template function for client-side component creation */
24
24
  template?: (props: Record<string, unknown>) => string
25
- /** When true, use comment-based scope hydration (fragment roots) */
25
+ /**
26
+ * When true, this component's scope has no `bf-s`-carrying element of
27
+ * its own — a proxy element stands in for it. Set for TWO distinct
28
+ * shapes (see `fragmentRoot` below, which tells them apart):
29
+ * - a genuine fragment root (`<>...</>`), where the proxy is the
30
+ * fragment's own rendered content;
31
+ * - a root that is itself a single child component call, where the
32
+ * proxy is that child's own already-scoped element (#2649).
33
+ */
26
34
  comment?: boolean
35
+ /**
36
+ * True only for the genuine-fragment-root shape of `comment` above
37
+ * (`ir.root.type === 'fragment'`) — never for the root-is-a-child-call
38
+ * shape. `materializeComponent` (component.ts) uses this to decide
39
+ * whether a CSR mount must generate its OWN scope id (fragment root: yes,
40
+ * so nested `renderChild()` calls get parent-prefixed naming matching
41
+ * SSR/hydrate) or leave the scope id null to avoid overwriting the
42
+ * child's own (#2649's shape, #2722).
43
+ */
44
+ fragmentRoot?: boolean
27
45
  }