@barefootjs/client 0.1.0

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.
Files changed (108) hide show
  1. package/dist/build.d.ts +56 -0
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/build.js +76 -0
  4. package/dist/context.d.ts +25 -0
  5. package/dist/context.d.ts.map +1 -0
  6. package/dist/csr-adapter.d.ts +26 -0
  7. package/dist/csr-adapter.d.ts.map +1 -0
  8. package/dist/forward-props.d.ts +17 -0
  9. package/dist/forward-props.d.ts.map +1 -0
  10. package/dist/index.d.ts +8 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +154 -0
  13. package/dist/reactive.d.ts +150 -0
  14. package/dist/reactive.d.ts.map +1 -0
  15. package/dist/reactive.js +215 -0
  16. package/dist/runtime/apply-rest-attrs.d.ts +16 -0
  17. package/dist/runtime/apply-rest-attrs.d.ts.map +1 -0
  18. package/dist/runtime/branch-slot.d.ts +22 -0
  19. package/dist/runtime/branch-slot.d.ts.map +1 -0
  20. package/dist/runtime/client-marker.d.ts +21 -0
  21. package/dist/runtime/client-marker.d.ts.map +1 -0
  22. package/dist/runtime/component.d.ts +99 -0
  23. package/dist/runtime/component.d.ts.map +1 -0
  24. package/dist/runtime/context.d.ts +40 -0
  25. package/dist/runtime/context.d.ts.map +1 -0
  26. package/dist/runtime/hydrate.d.ts +100 -0
  27. package/dist/runtime/hydrate.d.ts.map +1 -0
  28. package/dist/runtime/hydration-state.d.ts +13 -0
  29. package/dist/runtime/hydration-state.d.ts.map +1 -0
  30. package/dist/runtime/index.d.ts +27 -0
  31. package/dist/runtime/index.d.ts.map +1 -0
  32. package/dist/runtime/index.js +2093 -0
  33. package/dist/runtime/insert.d.ts +75 -0
  34. package/dist/runtime/insert.d.ts.map +1 -0
  35. package/dist/runtime/list.d.ts +21 -0
  36. package/dist/runtime/list.d.ts.map +1 -0
  37. package/dist/runtime/map-array.d.ts +32 -0
  38. package/dist/runtime/map-array.d.ts.map +1 -0
  39. package/dist/runtime/portal.d.ts +96 -0
  40. package/dist/runtime/portal.d.ts.map +1 -0
  41. package/dist/runtime/qsa-item.d.ts +52 -0
  42. package/dist/runtime/qsa-item.d.ts.map +1 -0
  43. package/dist/runtime/query.d.ts +86 -0
  44. package/dist/runtime/query.d.ts.map +1 -0
  45. package/dist/runtime/reconcile-elements.d.ts +44 -0
  46. package/dist/runtime/reconcile-elements.d.ts.map +1 -0
  47. package/dist/runtime/registry.d.ts +53 -0
  48. package/dist/runtime/registry.d.ts.map +1 -0
  49. package/dist/runtime/render.d.ts +35 -0
  50. package/dist/runtime/render.d.ts.map +1 -0
  51. package/dist/runtime/scope.d.ts +28 -0
  52. package/dist/runtime/scope.d.ts.map +1 -0
  53. package/dist/runtime/slot-resolver.d.ts +36 -0
  54. package/dist/runtime/slot-resolver.d.ts.map +1 -0
  55. package/dist/runtime/spread-attrs.d.ts +19 -0
  56. package/dist/runtime/spread-attrs.d.ts.map +1 -0
  57. package/dist/runtime/standalone.js +2278 -0
  58. package/dist/runtime/streaming.d.ts +36 -0
  59. package/dist/runtime/streaming.d.ts.map +1 -0
  60. package/dist/runtime/style.d.ts +17 -0
  61. package/dist/runtime/style.d.ts.map +1 -0
  62. package/dist/runtime/template.d.ts +39 -0
  63. package/dist/runtime/template.d.ts.map +1 -0
  64. package/dist/runtime/types.d.ts +26 -0
  65. package/dist/runtime/types.d.ts.map +1 -0
  66. package/dist/shims.d.ts +21 -0
  67. package/dist/shims.d.ts.map +1 -0
  68. package/dist/slot.d.ts +14 -0
  69. package/dist/slot.d.ts.map +1 -0
  70. package/dist/split-props.d.ts +26 -0
  71. package/dist/split-props.d.ts.map +1 -0
  72. package/dist/unwrap.d.ts +16 -0
  73. package/dist/unwrap.d.ts.map +1 -0
  74. package/package.json +71 -0
  75. package/src/build.ts +92 -0
  76. package/src/context.ts +33 -0
  77. package/src/csr-adapter.ts +134 -0
  78. package/src/forward-props.ts +43 -0
  79. package/src/index.ts +42 -0
  80. package/src/reactive.ts +411 -0
  81. package/src/runtime/apply-rest-attrs.ts +109 -0
  82. package/src/runtime/branch-slot.ts +32 -0
  83. package/src/runtime/client-marker.ts +46 -0
  84. package/src/runtime/component.ts +501 -0
  85. package/src/runtime/context.ts +111 -0
  86. package/src/runtime/hydrate.ts +311 -0
  87. package/src/runtime/hydration-state.ts +13 -0
  88. package/src/runtime/index.ts +96 -0
  89. package/src/runtime/insert.ts +407 -0
  90. package/src/runtime/list.ts +47 -0
  91. package/src/runtime/map-array.ts +381 -0
  92. package/src/runtime/portal.ts +174 -0
  93. package/src/runtime/qsa-item.ts +128 -0
  94. package/src/runtime/query.ts +632 -0
  95. package/src/runtime/reconcile-elements.ts +391 -0
  96. package/src/runtime/registry.ts +160 -0
  97. package/src/runtime/render.ts +105 -0
  98. package/src/runtime/scope.ts +46 -0
  99. package/src/runtime/slot-resolver.ts +66 -0
  100. package/src/runtime/spread-attrs.ts +88 -0
  101. package/src/runtime/streaming.ts +65 -0
  102. package/src/runtime/style.ts +27 -0
  103. package/src/runtime/template.ts +53 -0
  104. package/src/runtime/types.ts +27 -0
  105. package/src/shims.ts +54 -0
  106. package/src/slot.ts +23 -0
  107. package/src/split-props.ts +86 -0
  108. package/src/unwrap.ts +18 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * BarefootJS - Per-Item Reactive List Rendering
3
+ *
4
+ * Maps a reactive array to DOM elements with per-item scoping.
5
+ * Each item is rendered in its own createRoot with a per-item signal.
6
+ * When the array changes, same-key items UPDATE their signal instead of
7
+ * being disposed and recreated — fine-grained effects handle DOM updates.
8
+ *
9
+ * Unified CSR/SSR: renderItem receives an optional existing element.
10
+ * For SSR hydration, the existing DOM element is passed so renderItem
11
+ * can initialize it (initChild) instead of creating a new one (createComponent).
12
+ *
13
+ * Multi-root items (#1212): when the loop body is a JSX Fragment with two
14
+ * or more sibling elements, the compiler emits a `<!--bf-loop-i-->`
15
+ * comment before each item's roots. This module partitions the loop range
16
+ * by those markers so one logical item — the (startMarker, primaryEl,
17
+ * extras...) triple — moves, mounts, and unmounts as a single unit.
18
+ * Single-root loops continue to flow through the legacy path verbatim.
19
+ */
20
+
21
+ import { createSignal, createEffect, createRoot } from '@barefootjs/client/reactive'
22
+ import { hydratedScopes } from './hydration-state'
23
+ import {
24
+ BF_KEY,
25
+ BF_LOOP_START,
26
+ BF_LOOP_END,
27
+ BF_LOOP_ITEM,
28
+ loopStartMarker,
29
+ loopEndMarker,
30
+ } from '@barefootjs/shared'
31
+
32
+ type ItemScope<T> = {
33
+ /**
34
+ * `<!--bf-loop-i-->` Comment that anchors a multi-root item. `null` for
35
+ * single-root items — keeps the common path mutation-equivalent to the
36
+ * legacy implementation.
37
+ */
38
+ startMarker: Comment | null
39
+ /**
40
+ * The first real Element of the item — what `renderItem` returned and
41
+ * what reactive effects, event delegation, and `qsa` lookups operate
42
+ * on. Always present; multi-root items also carry `extras`.
43
+ */
44
+ primaryEl: HTMLElement
45
+ /**
46
+ * Additional sibling root elements for multi-root items (Fragment with
47
+ * two or more peers). Empty for single-root items.
48
+ */
49
+ extras: HTMLElement[]
50
+ dispose: () => void
51
+ setItem: (v: T) => void
52
+ }
53
+
54
+ /**
55
+ * Find loop boundary comment markers in a container.
56
+ *
57
+ * When `markerId` is given, matches the scoped form `<!--bf-loop:<id>-->` /
58
+ * `<!--bf-/loop:<id>-->` so sibling `.map()` calls under the same parent
59
+ * each see only their own range (#1087).
60
+ *
61
+ * When omitted (e.g. hand-written tests that drop in unscoped markers),
62
+ * falls back to the first start / first end found, matching either the
63
+ * scoped or legacy unscoped form.
64
+ */
65
+ function findLoopMarkers(
66
+ container: HTMLElement,
67
+ markerId?: string,
68
+ ): { start: Comment | null; end: Comment | null } {
69
+ let start: Comment | null = null
70
+ let end: Comment | null = null
71
+ if (markerId) {
72
+ const startVal = loopStartMarker(markerId)
73
+ const endVal = loopEndMarker(markerId)
74
+ for (const node of Array.from(container.childNodes)) {
75
+ if (node.nodeType !== Node.COMMENT_NODE) continue
76
+ const value = (node as Comment).nodeValue
77
+ if (value === startVal) start = node as Comment
78
+ else if (value === endVal) end = node as Comment
79
+ }
80
+ } else {
81
+ const startPrefix = `${BF_LOOP_START}:`
82
+ const endPrefix = `${BF_LOOP_END}:`
83
+ for (const node of Array.from(container.childNodes)) {
84
+ if (node.nodeType !== Node.COMMENT_NODE) continue
85
+ const value = (node as Comment).nodeValue ?? ''
86
+ if (!start && (value === BF_LOOP_START || value.startsWith(startPrefix))) {
87
+ start = node as Comment
88
+ } else if (!end && (value === BF_LOOP_END || value.startsWith(endPrefix))) {
89
+ end = node as Comment
90
+ }
91
+ }
92
+ }
93
+ if (start && end) return { start, end }
94
+ return { start: null, end: null }
95
+ }
96
+
97
+ /**
98
+ * Partition the nodes between loop boundary markers into one entry per
99
+ * logical item. When `<!--bf-loop-i-->` markers are present, each marker
100
+ * opens a new item range and the following Element nodes become its
101
+ * `primaryEl` (first) and `extras` (subsequent). When no per-item markers
102
+ * are present (single-root loops, the common case), each Element forms
103
+ * its own range with `startMarker: null` and `extras: []` — preserving
104
+ * legacy behavior verbatim.
105
+ */
106
+ function findItemRanges(start: Comment, end: Comment): Array<{
107
+ startMarker: Comment | null
108
+ primaryEl: HTMLElement
109
+ extras: HTMLElement[]
110
+ }> {
111
+ const ranges: Array<{
112
+ startMarker: Comment | null
113
+ primaryEl: HTMLElement | null
114
+ extras: HTMLElement[]
115
+ }> = []
116
+ let current: { startMarker: Comment | null; primaryEl: HTMLElement | null; extras: HTMLElement[] } | null = null
117
+ let sawItemMarker = false
118
+ let node: Node | null = start.nextSibling
119
+ while (node && node !== end) {
120
+ if (node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === BF_LOOP_ITEM) {
121
+ sawItemMarker = true
122
+ current = { startMarker: node as Comment, primaryEl: null, extras: [] }
123
+ ranges.push(current)
124
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
125
+ const el = node as HTMLElement
126
+ if (sawItemMarker) {
127
+ if (!current!.primaryEl) current!.primaryEl = el
128
+ else current!.extras.push(el)
129
+ } else {
130
+ ranges.push({ startMarker: null, primaryEl: el, extras: [] })
131
+ }
132
+ }
133
+ node = node.nextSibling
134
+ }
135
+ return ranges.filter(
136
+ (r): r is { startMarker: Comment | null; primaryEl: HTMLElement; extras: HTMLElement[] } =>
137
+ r.primaryEl !== null,
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Insert a scope's nodes into the container in their canonical order
143
+ * (startMarker → primaryEl → extras). Idempotent — `insertBefore` on a
144
+ * node already at the target position is a no-op.
145
+ */
146
+ function insertScope<T>(scope: ItemScope<T>, container: HTMLElement, anchor: Node | null): void {
147
+ if (scope.startMarker) container.insertBefore(scope.startMarker, anchor)
148
+ container.insertBefore(scope.primaryEl, anchor)
149
+ for (const ex of scope.extras) container.insertBefore(ex, anchor)
150
+ }
151
+
152
+ /** Detach all of a scope's nodes from the DOM. */
153
+ function removeScope<T>(scope: ItemScope<T>): void {
154
+ if (scope.startMarker?.parentNode) scope.startMarker.remove()
155
+ if (scope.primaryEl.parentNode) scope.primaryEl.remove()
156
+ for (const ex of scope.extras) {
157
+ if (ex.parentNode) ex.remove()
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Create an item in its own reactive scope with a per-item signal.
163
+ * renderItem receives a signal accessor for the item, so fine-grained
164
+ * effects can re-run when the item signal is updated via setItem().
165
+ *
166
+ * Multi-root handling: on CSR the emitted renderItem stashes any extra
167
+ * sibling roots on the returned element via a `__bfExtras` property that
168
+ * we read-and-delete here. On hydration the caller passes `existingExtras`
169
+ * + `existingStart` collected from the SSR partition.
170
+ */
171
+ function createItemScope<T>(
172
+ item: T,
173
+ index: number,
174
+ renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
175
+ existingPrimary?: HTMLElement,
176
+ existingExtras?: HTMLElement[],
177
+ existingStart?: Comment | null,
178
+ ): ItemScope<T> {
179
+ let primaryEl!: HTMLElement
180
+ let dispose!: () => void
181
+ let setItem!: (v: T) => void
182
+ let extras: HTMLElement[] = []
183
+ let startMarker: Comment | null = null
184
+
185
+ createRoot((d) => {
186
+ dispose = d
187
+ const [itemAccessor, itemSetter] = createSignal(item)
188
+ setItem = itemSetter
189
+ primaryEl = renderItem(itemAccessor, index, existingPrimary)
190
+ if (existingPrimary) {
191
+ extras = existingExtras ?? []
192
+ startMarker = existingStart ?? null
193
+ } else {
194
+ const stashed = (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
195
+ if (stashed && stashed.length > 0) {
196
+ extras = stashed
197
+ startMarker = document.createComment(BF_LOOP_ITEM)
198
+ }
199
+ delete (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
200
+ }
201
+ return undefined
202
+ })
203
+
204
+ return { startMarker, primaryEl, extras, dispose, setItem }
205
+ }
206
+
207
+ /**
208
+ * Per-item scoped list rendering.
209
+ *
210
+ * @param accessor - Function returning the reactive array (signal/memo read)
211
+ * @param container - DOM container element
212
+ * @param getKey - Key extractor (null = use index). Receives plain item value.
213
+ * @param renderItem - Creates or initializes an HTMLElement for an item (runs in createRoot).
214
+ * Receives item as signal accessor: item() returns current value.
215
+ * When `existing` is passed, initializes the SSR-rendered element and returns it.
216
+ * When `existing` is undefined, creates a new element and returns it.
217
+ */
218
+ export function mapArray<T>(
219
+ accessor: () => T[],
220
+ container: HTMLElement | null,
221
+ getKey: ((item: T, index: number) => string) | null,
222
+ renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
223
+ markerId?: string,
224
+ ): void {
225
+ if (!container) return
226
+
227
+ const scopes = new Map<string, ItemScope<T>>()
228
+ let hydrated = false
229
+
230
+ createEffect(() => {
231
+ const items = accessor()
232
+ if (!items) return
233
+
234
+ const { start: startMarker, end: endMarker } = findLoopMarkers(container, markerId)
235
+ const anchor = endMarker ?? null
236
+
237
+ // --- First run: hydrate SSR-rendered children ---
238
+ if (!hydrated) {
239
+ hydrated = true
240
+ const existingRanges = startMarker
241
+ ? findItemRanges(startMarker, endMarker!)
242
+ : Array.from(container.children).map(
243
+ (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
244
+ )
245
+
246
+ // SSR elements need initialization when they haven't been adopted into scopes yet.
247
+ // Check both: elements without data-key (legacy) OR elements with data-key but no scopes
248
+ // (component loops render data-key in SSR template but haven't been hydrated).
249
+ const needsHydration = existingRanges.length > 0
250
+ && (!existingRanges[0]?.primaryEl.hasAttribute('data-key') || scopes.size === 0)
251
+ if (needsHydration) {
252
+ // Hydrate in place: tag keys, create per-item scopes with renderItem(existing)
253
+ for (let i = 0; i < existingRanges.length && i < items.length; i++) {
254
+ const range = existingRanges[i]
255
+ const item = items[i]
256
+ const key = getKey ? getKey(item, i) : String(i)
257
+ range.primaryEl.setAttribute(BF_KEY, key)
258
+
259
+ const scope = createItemScope(
260
+ item,
261
+ i,
262
+ renderItem,
263
+ range.primaryEl,
264
+ range.extras,
265
+ range.startMarker,
266
+ )
267
+ scopes.set(key, scope)
268
+ hydratedScopes.add(range.primaryEl)
269
+ }
270
+
271
+ // If SSR had fewer items than current array, create remaining (CSR)
272
+ for (let i = existingRanges.length; i < items.length; i++) {
273
+ const item = items[i]
274
+ const key = getKey ? getKey(item, i) : String(i)
275
+ const scope = createItemScope(item, i, renderItem)
276
+ if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
277
+ scopes.set(key, scope)
278
+ insertScope(scope, container, anchor)
279
+ }
280
+ return // Hydration complete — effects handle future updates
281
+ }
282
+ }
283
+
284
+ // --- Adopt any existing keyed elements not yet in scopes ---
285
+ if (scopes.size === 0) {
286
+ const loopRanges = startMarker
287
+ ? findItemRanges(startMarker, endMarker!)
288
+ : Array.from(container.children).map(
289
+ (el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
290
+ )
291
+ for (const range of loopRanges) {
292
+ const existingKey = range.primaryEl.dataset?.key
293
+ if (existingKey && !scopes.has(existingKey)) {
294
+ scopes.set(existingKey, {
295
+ startMarker: range.startMarker,
296
+ primaryEl: range.primaryEl,
297
+ extras: range.extras,
298
+ dispose: () => {},
299
+ setItem: () => {},
300
+ })
301
+ }
302
+ }
303
+ }
304
+
305
+ // --- Key-based diff ---
306
+ const newKeys = new Set<string>()
307
+ // Distinct from `newKeys`: tracks which keys have ALREADY emitted a
308
+ // duplicate warning in this reconcile, so a 1000-item list where
309
+ // every item shares one key emits ONE warning, not 999. (#1244 follow-up.)
310
+ const warnedKeys = new Set<string>()
311
+ const desiredOrder: ItemScope<T>[] = []
312
+
313
+ for (let i = 0; i < items.length; i++) {
314
+ const item = items[i]
315
+ const key = getKey ? getKey(item, i) : String(i)
316
+ if (newKeys.has(key) && !warnedKeys.has(key)) {
317
+ warnedKeys.add(key)
318
+ // The reconciler maps each unique key to a single scope, so a
319
+ // second item with the same key overwrites the first scope's
320
+ // data via `setItem` and effectively collapses every duplicate
321
+ // into one rendered DOM node. The "list silently renders fewer
322
+ // items than the array" failure mode used to be caught at
323
+ // compile time before #1358 narrowed BF023.
324
+ console.warn(
325
+ `[BarefootJS] mapArray: duplicate key "${key}" — items with this key collapse to a single DOM scope, ` +
326
+ `so only the last one renders. Use a per-item identifier (e.g. \`key={item.id}\`) for correct reconciliation.`,
327
+ )
328
+ }
329
+ newKeys.add(key)
330
+
331
+ const existing = scopes.get(key)
332
+ if (existing) {
333
+ // Same key: update per-item signal — fine-grained effects handle DOM updates.
334
+ // Element is preserved (no dispose, no re-render).
335
+ existing.setItem(item)
336
+ desiredOrder.push(existing)
337
+ } else {
338
+ // New item: create in isolated scope
339
+ const scope = createItemScope(item, i, renderItem)
340
+ if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
341
+ scopes.set(key, scope)
342
+ desiredOrder.push(scope)
343
+ }
344
+ }
345
+
346
+ // Remove items no longer in the array
347
+ for (const [key, scope] of scopes) {
348
+ if (!newKeys.has(key)) {
349
+ scope.dispose()
350
+ removeScope(scope)
351
+ scopes.delete(key)
352
+ }
353
+ }
354
+
355
+ // Reconcile DOM order: skip insertBefore entirely when order is unchanged.
356
+ // Moving elements via insertBefore causes detach/reattach which makes
357
+ // focused inputs lose focus (controlled input flicker). Each scope can
358
+ // span multiple nodes (startMarker + primaryEl + extras), so the walk
359
+ // consumes the full range when a primaryEl matches.
360
+ let inOrder = true
361
+ let checkNode: Node | null = startMarker ? startMarker.nextSibling : container.firstChild
362
+ for (const scope of desiredOrder) {
363
+ // Skip non-element nodes (comments, text) when looking for the primary element.
364
+ while (checkNode && checkNode.nodeType !== Node.ELEMENT_NODE) checkNode = checkNode.nextSibling
365
+ if (checkNode !== scope.primaryEl) { inOrder = false; break }
366
+ // Advance past the rest of the scope's extras.
367
+ checkNode = checkNode.nextSibling
368
+ for (let i = 0; i < scope.extras.length; i++) {
369
+ while (checkNode && checkNode.nodeType !== Node.ELEMENT_NODE) checkNode = checkNode.nextSibling
370
+ if (checkNode !== scope.extras[i]) { inOrder = false; break }
371
+ checkNode = checkNode.nextSibling
372
+ }
373
+ if (!inOrder) break
374
+ }
375
+ if (!inOrder) {
376
+ for (const scope of desiredOrder) {
377
+ insertScope(scope, container, anchor)
378
+ }
379
+ }
380
+ })
381
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * BarefootJS - Portal Utility
3
+ *
4
+ * Client-side utility to mount elements at arbitrary DOM positions.
5
+ * Typically used for modals, tooltips, and other overlay UI.
6
+ *
7
+ * API inspired by React's createPortal(children, domNode).
8
+ */
9
+
10
+ import { BF_SCOPE, BF_PORTAL_ID, BF_PORTAL_OWNER, BF_PORTAL_PLACEHOLDER } from '@barefootjs/shared'
11
+ import { parseHTML } from './component'
12
+ import { getPortalScopeId } from './scope'
13
+
14
+ export type Portal = {
15
+ /** The mounted element */
16
+ element: HTMLElement
17
+ /** Remove the mounted element from the DOM */
18
+ unmount: () => void
19
+ }
20
+
21
+ /**
22
+ * Options for createPortal
23
+ */
24
+ export interface PortalOptions {
25
+ /**
26
+ * The scope element that owns this portal.
27
+ * When provided, the portal element will have a bf-po attribute
28
+ * set to the scope ID, allowing find() to locate elements inside the portal.
29
+ */
30
+ ownerScope?: Element
31
+ }
32
+
33
+ /** Anything that can be converted to HTML string via toString() */
34
+ export type Renderable = { toString(): string }
35
+
36
+ /** Valid children types for createPortal */
37
+ export type PortalChildren = HTMLElement | string | Renderable
38
+
39
+ /**
40
+ * Create a portal to mount an element at a specific container
41
+ *
42
+ * Similar to React's createPortal(children, domNode), this function
43
+ * mounts the given element/HTML to the specified container.
44
+ *
45
+ * @param children - Element to mount (HTMLElement, HTML string, or JSX.Element)
46
+ * @param container - Target container element (defaults to document.body)
47
+ * @param options - Optional configuration including ownerScope for scope-based find()
48
+ * @returns Portal object with element reference and unmount method
49
+ *
50
+ * @example
51
+ * // With HTML string
52
+ * const portal = createPortal(`
53
+ * <div class="modal-overlay">
54
+ * <div class="modal" role="dialog" aria-modal="true">
55
+ * Modal content
56
+ * </div>
57
+ * </div>
58
+ * `, document.body)
59
+ *
60
+ * // With HTMLElement
61
+ * const modalEl = document.createElement('div')
62
+ * modalEl.className = 'modal'
63
+ * const portal = createPortal(modalEl, document.body)
64
+ *
65
+ * // With JSX.Element (Hono)
66
+ * const portal = createPortal(<Modal />, document.body)
67
+ *
68
+ * // With ownerScope for scope-based element detection
69
+ * const portal = createPortal(modalEl, document.body, { ownerScope: scopeElement })
70
+ *
71
+ * // Access the mounted element
72
+ * console.log(portal.element)
73
+ *
74
+ * // Later: unmount
75
+ * portal.unmount()
76
+ */
77
+ /**
78
+ * Check if an element is inside an SSR-rendered portal.
79
+ * SSR portals are marked with bf-pi attribute.
80
+ *
81
+ * @param element - Element to check
82
+ * @returns true if element is inside an SSR portal
83
+ */
84
+ export function isSSRPortal(element: HTMLElement): boolean {
85
+ return element.closest(`[${BF_PORTAL_ID}]`) !== null
86
+ }
87
+
88
+ /**
89
+ * Remove a portal placeholder element (used after hydration).
90
+ * SSR Portal renders a <template bf-pp="..."> as a marker.
91
+ *
92
+ * @param portalId - The portal ID to find and remove
93
+ */
94
+ /**
95
+ * Find a sibling slot element relative to the given element.
96
+ * Handles the SSR portal case where the element is inside a portal wrapper
97
+ * (bf-pi) instead of its original parent container.
98
+ *
99
+ * @param el - Element to search from
100
+ * @param slotSelector - CSS selector for the sibling slot (e.g., '[data-slot="popover-trigger"]')
101
+ * @returns The found element, or null
102
+ */
103
+ export function findSiblingSlot(el: HTMLElement, slotSelector: string): HTMLElement | null {
104
+ // Direct parent lookup (normal case)
105
+ const direct = el.parentElement?.querySelector(slotSelector) as HTMLElement | null
106
+ if (direct) return direct
107
+
108
+ // SSR portal fallback: use bf-po (owner scope ID) to find the original container
109
+ const portalWrapper = el.closest(`[${BF_PORTAL_ID}]`)
110
+ if (!portalWrapper) return null
111
+
112
+ const ownerScopeId = portalWrapper.getAttribute(BF_PORTAL_OWNER)
113
+ if (!ownerScopeId) return null
114
+
115
+ // Find owner scope by exact bf-s match (#1249 — no `~` prefix).
116
+ const ownerScope = document.querySelector(`[${BF_SCOPE}="${ownerScopeId}"]`)
117
+ if (!ownerScope) return null
118
+
119
+ return ownerScope.querySelector(slotSelector) as HTMLElement | null
120
+ }
121
+
122
+ export function cleanupPortalPlaceholder(portalId: string): void {
123
+ const placeholder = document.querySelector(
124
+ `template[${BF_PORTAL_PLACEHOLDER}="${portalId}"]`
125
+ )
126
+ placeholder?.remove()
127
+ }
128
+
129
+ export function createPortal(
130
+ children: PortalChildren,
131
+ container: HTMLElement = document.body,
132
+ options?: PortalOptions
133
+ ): Portal {
134
+ let element: HTMLElement
135
+
136
+ if (children instanceof HTMLElement) {
137
+ element = children
138
+ } else {
139
+ // Convert to string (handles both string and Renderable)
140
+ const html = typeof children === 'string' ? children : children.toString()
141
+
142
+ const parsed = parseHTML(html).firstElementChild as HTMLElement
143
+
144
+ if (!parsed) {
145
+ throw new Error('createPortal: Invalid HTML provided')
146
+ }
147
+
148
+ element = parsed
149
+ }
150
+
151
+ // Set portal owner for scope-based find()
152
+ if (options?.ownerScope) {
153
+ // Check bf-s attribute first, then fall back to comment scope registry.
154
+ // bf-s is the bare addressable id (#1249), suitable for bf-po as-is.
155
+ let scopeId: string | null = (options.ownerScope as HTMLElement).getAttribute?.(BF_SCOPE) ?? null
156
+ if (!scopeId) {
157
+ scopeId = getPortalScopeId(options.ownerScope) ?? null
158
+ }
159
+ if (scopeId) {
160
+ element.setAttribute(BF_PORTAL_OWNER, scopeId)
161
+ }
162
+ }
163
+
164
+ container.appendChild(element)
165
+
166
+ return {
167
+ element,
168
+ unmount(): void {
169
+ if (element.parentNode) {
170
+ element.parentNode.removeChild(element)
171
+ }
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Multi-root-aware slot lookup + child upsert for `mapArray` items whose
3
+ * body is a JSX Fragment with two or more sibling elements (#1212).
4
+ *
5
+ * In a single-root loop, every reactive slot inside a renderItem body is a
6
+ * descendant of `__el`, so plain `qsa(__el, ...)` finds it. With a
7
+ * multi-root Fragment item the second / third / Nth root are *siblings* of
8
+ * `__el` rather than descendants — `__el.querySelector(...)` will silently
9
+ * miss them, leaving reactive attributes / event handlers unbound, and
10
+ * `upsertChild(__el, ...)` will fail to find child component scope
11
+ * elements that live on a sibling root.
12
+ *
13
+ * The compiler emits `qsaItem` / `upsertChildItem` for these cases. Both
14
+ * iterate the same set of "item root elements":
15
+ *
16
+ * 1. The primary element itself.
17
+ * 2. Sibling roots that follow it in the DOM, until a loop boundary
18
+ * Comment is reached (`<!--bf-loop-i-->`, `<!--bf-loop:*-->`,
19
+ * `<!--bf-/loop:*-->`). These bound the item's range so a lookup
20
+ * cannot escape into a neighbouring item or into nodes outside the
21
+ * loop.
22
+ * 3. The CSR-only `__bfExtras` stash. During renderItem-body setup
23
+ * (between the template clone and the function's return), the
24
+ * primary and extras are still detached nodes — `__el.nextSibling`
25
+ * is `null` and step 2 yields nothing. Reading `__bfExtras` lets
26
+ * lookups reach the still-pending extras before `mapArray` inserts
27
+ * them into the DOM.
28
+ */
29
+
30
+ import { BF_LOOP_ITEM, BF_LOOP_START, BF_LOOP_END } from '@barefootjs/shared'
31
+ import { initChild } from './registry'
32
+ import { createComponent } from './component'
33
+ import { findSsrScopeBySlotIn, buildSlotInfo } from './slot-resolver'
34
+
35
+ /** Iterate the elements that belong to an item — primary, in-tree siblings within bounds, then any pre-insertion extras stash. */
36
+ function* itemRootElements(primaryEl: Element): Iterable<Element> {
37
+ yield primaryEl
38
+ const startPrefix = `${BF_LOOP_START}:`
39
+ const endPrefix = `${BF_LOOP_END}:`
40
+ let n: Node | null = primaryEl.nextSibling
41
+ while (n) {
42
+ if (n.nodeType === Node.COMMENT_NODE) {
43
+ const v = (n as Comment).nodeValue ?? ''
44
+ // Hard stops: another item starts, or the loop range ends. The
45
+ // BF_LOOP_START check defends against a sibling loop block whose
46
+ // start marker happens to follow ours.
47
+ if (v === BF_LOOP_ITEM
48
+ || v === BF_LOOP_START || v.startsWith(startPrefix)
49
+ || v === BF_LOOP_END || v.startsWith(endPrefix)) {
50
+ return
51
+ }
52
+ } else if (n.nodeType === Node.ELEMENT_NODE) {
53
+ yield n as Element
54
+ }
55
+ n = n.nextSibling
56
+ }
57
+ // CSR pre-insertion path: extras are not yet siblings in the DOM, but
58
+ // the compiler stashed them on the primary so the renderItem body can
59
+ // still reach them during setup.
60
+ const stashed = (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
61
+ if (stashed) {
62
+ for (const ex of stashed) yield ex
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Find an element matching `selector` within an item's range. Searches
68
+ * the primary's descendants first, then walks each root in
69
+ * `itemRootElements`, returning the first match.
70
+ */
71
+ export function qsaItem(primaryEl: Element | null, selector: string): Element | null {
72
+ if (!primaryEl) return null
73
+ for (const root of itemRootElements(primaryEl)) {
74
+ if (root.matches(selector)) return root
75
+ const inner = root.querySelector(selector)
76
+ if (inner) return inner
77
+ }
78
+ return null
79
+ }
80
+
81
+ /**
82
+ * Multi-root-aware variant of `upsertChild`. Looks for the SSR scope
83
+ * element (or CSR placeholder) anywhere within the item's range —
84
+ * descendants of the primary root, sibling Fragment roots in the DOM,
85
+ * or the pre-insertion `__bfExtras` stash — so a child component
86
+ * carried by any root of a multi-root loop body is initialised
87
+ * correctly (#1212).
88
+ *
89
+ * Uses `qsaItem`-style search (root-or-descendant per element) so it
90
+ * also matches when a sibling root *is* the component scope element
91
+ * itself, not just a parent of it.
92
+ *
93
+ * Mirrors `upsertChild`'s #1220 collision skip: slotId-suffix candidates
94
+ * with a deeper `_sN_sN` shape (a synthesized child's nested scope path)
95
+ * are ignored so `initChild` doesn't fire on the wrong element.
96
+ */
97
+ export function upsertChildItem(
98
+ primaryEl: Element,
99
+ name: string,
100
+ slotId: string | null,
101
+ props: Record<string, unknown>,
102
+ key?: string | number,
103
+ anchorScope?: Element | null,
104
+ ): HTMLElement | null {
105
+ let ssr: HTMLElement | null = null
106
+ if (slotId) {
107
+ for (const root of itemRootElements(primaryEl)) {
108
+ const found = findSsrScopeBySlotIn(root, slotId, anchorScope, /* selfMatch */ true)
109
+ if (found) { ssr = found; break }
110
+ }
111
+ } else {
112
+ ssr = qsaItem(primaryEl, `[bf-s^="${name}_"]`) as HTMLElement | null
113
+ }
114
+ if (ssr) {
115
+ initChild(name, ssr, props)
116
+ return ssr
117
+ }
118
+ // CSR: replace placeholder with a freshly-created component.
119
+ const phId = slotId ?? name
120
+ const ph = qsaItem(primaryEl, `[data-bf-ph="${phId}"]`) as HTMLElement | null
121
+ if (ph) {
122
+ const slot = slotId ? buildSlotInfo(primaryEl, slotId, anchorScope) : undefined
123
+ const comp = createComponent(name, props, key, slot)
124
+ ph.replaceWith(comp)
125
+ return comp
126
+ }
127
+ return null
128
+ }