@barefootjs/client 0.33.0 → 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.
@@ -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
  /**
@@ -352,7 +457,7 @@ export function mapArray<T>(
352
457
  const existingRanges = startMarker
353
458
  ? findItemRanges(startMarker, endMarker!)
354
459
  : Array.from(container.children).map(
355
- (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
460
+ (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[], scopeComments: null as ScopeCommentPair | null }),
356
461
  )
357
462
 
358
463
  // SSR elements need initialization when they haven't been adopted into scopes yet.
@@ -375,6 +480,7 @@ export function mapArray<T>(
375
480
  range.primaryEl,
376
481
  range.extras,
377
482
  range.startMarker,
483
+ range.scopeComments,
378
484
  )
379
485
  scopes.set(key, scope)
380
486
  hydratedScopes.add(range.primaryEl)
@@ -386,7 +492,7 @@ export function mapArray<T>(
386
492
  const key = getKey ? getKey(item, i) : String(i)
387
493
  // Final position is known here (append before the loop's trailing
388
494
  // 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 })
495
+ const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, undefined, { container, anchor })
390
496
  if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
391
497
  scopes.set(key, scope)
392
498
  insertScope(scope, container, anchor)
@@ -396,7 +502,9 @@ export function mapArray<T>(
396
502
  for (let i = items.length; i < existingRanges.length; i++) {
397
503
  const range = existingRanges[i]
398
504
  if (range.startMarker?.parentNode) range.startMarker.remove()
505
+ if (range.scopeComments?.start.parentNode) range.scopeComments.start.remove()
399
506
  if (range.primaryEl.parentNode) range.primaryEl.remove()
507
+ if (range.scopeComments?.end.parentNode) range.scopeComments.end.remove()
400
508
  for (const ex of range.extras) {
401
509
  if (ex.parentNode) ex.remove()
402
510
  }
@@ -411,7 +519,7 @@ export function mapArray<T>(
411
519
  const loopRanges = startMarker
412
520
  ? findItemRanges(startMarker, endMarker!)
413
521
  : Array.from(container.children).map(
414
- (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
522
+ (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[], scopeComments: null as ScopeCommentPair | null }),
415
523
  )
416
524
  for (const range of loopRanges) {
417
525
  const existingKey = range.primaryEl.dataset?.key
@@ -420,6 +528,7 @@ export function mapArray<T>(
420
528
  startMarker: range.startMarker,
421
529
  primaryEl: range.primaryEl,
422
530
  extras: range.extras,
531
+ scopeComments: range.scopeComments,
423
532
  dispose: () => {},
424
533
  setItem: () => {},
425
534
  })
@@ -450,6 +559,7 @@ export function mapArray<T>(
450
559
  let expectedNodeCount = 0
451
560
  for (const scope of scopes.values()) {
452
561
  expectedNodeCount += 1 + scope.extras.length + (scope.startMarker ? 1 : 0)
562
+ + (scope.scopeComments ? 2 : 0)
453
563
  }
454
564
  let actualNodeCount = 0
455
565
  for (let node = container.firstChild; node; node = node.nextSibling) actualNodeCount++
@@ -501,7 +611,7 @@ export function mapArray<T>(
501
611
  // the loop range before its init runs; the LIS reorder below moves it
502
612
  // to its final position (it participates in the walk like any other
503
613
  // attached scope, so the resulting order is unchanged).
504
- const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
614
+ const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, undefined, { container, anchor })
505
615
  if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
506
616
  scopes.set(key, scope)
507
617
  desiredOrder.push(scope)
@@ -586,9 +696,14 @@ export function mapArray<T>(
586
696
  while (j < desiredOrder.length && !stationary[j]) j++
587
697
  // Insert this run immediately before the next stationary scope (which
588
698
  // 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.
699
+ // trailing anchor when the run reaches the end of the list. Preferring
700
+ // `scopeComments.start` over `primaryEl` here matters when that next
701
+ // scope is a fragment-root row (#2733): inserting directly before its
702
+ // `primaryEl` would land the new run BETWEEN its still-attached
703
+ // `<!--bf-scope:-->` start comment and its element, splitting a
704
+ // stationary scope's own boundary pair.
590
705
  const before = j < desiredOrder.length
591
- ? (desiredOrder[j].startMarker ?? desiredOrder[j].primaryEl)
706
+ ? (desiredOrder[j].startMarker ?? desiredOrder[j].scopeComments?.start ?? desiredOrder[j].primaryEl)
592
707
  : anchor
593
708
  if (j - i === 1) {
594
709
  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
  }