@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,391 @@
1
+ /**
2
+ * BarefootJS - Element-based List Reconciliation
3
+ *
4
+ * Key-based DOM reconciliation for component-based list rendering.
5
+ * Used when renderItem returns HTMLElement (via createComponent).
6
+ */
7
+
8
+ import { hydratedScopes } from './hydration-state'
9
+ import { BF_SCOPE, BF_SLOT, BF_COND, BF_KEY, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker } from '@barefootjs/shared'
10
+
11
+ /**
12
+ * Find loop boundary comment markers in a container.
13
+ *
14
+ * `markerId` scopes the lookup to `<!--bf-loop:<id>-->` / `<!--bf-/loop:<id>-->`
15
+ * so sibling loops under the same parent disambiguate (#1087). Without an id,
16
+ * accepts the legacy unscoped form too — used by tests that build containers
17
+ * without compiler-emitted markers.
18
+ */
19
+ function findLoopMarkers(
20
+ container: HTMLElement,
21
+ markerId?: string,
22
+ ): { startMarker: Comment | null; endMarker: Comment | null } {
23
+ let startMarker: Comment | null = null
24
+ let endMarker: Comment | null = null
25
+ if (markerId) {
26
+ const startVal = loopStartMarker(markerId)
27
+ const endVal = loopEndMarker(markerId)
28
+ for (const node of Array.from(container.childNodes)) {
29
+ if (node.nodeType !== Node.COMMENT_NODE) continue
30
+ const value = (node as Comment).nodeValue
31
+ if (value === startVal) startMarker = node as Comment
32
+ else if (value === endVal) endMarker = node as Comment
33
+ }
34
+ } else {
35
+ const startPrefix = `${BF_LOOP_START}:`
36
+ const endPrefix = `${BF_LOOP_END}:`
37
+ for (const node of Array.from(container.childNodes)) {
38
+ if (node.nodeType !== Node.COMMENT_NODE) continue
39
+ const value = (node as Comment).nodeValue ?? ''
40
+ if (!startMarker && (value === BF_LOOP_START || value.startsWith(startPrefix))) {
41
+ startMarker = node as Comment
42
+ } else if (!endMarker && (value === BF_LOOP_END || value.startsWith(endPrefix))) {
43
+ endMarker = node as Comment
44
+ }
45
+ }
46
+ }
47
+ if (startMarker && endMarker) return { startMarker, endMarker }
48
+ return { startMarker: null, endMarker: null }
49
+ }
50
+
51
+ /** Get all Element nodes between start and end comment markers. */
52
+ function getElementsBetweenMarkers(start: Comment, end: Comment): Element[] {
53
+ const elements: Element[] = []
54
+ let node: Node | null = start.nextSibling
55
+ while (node && node !== end) {
56
+ if (node.nodeType === Node.ELEMENT_NODE) {
57
+ elements.push(node as Element)
58
+ }
59
+ node = node.nextSibling
60
+ }
61
+ return elements
62
+ }
63
+
64
+ /** Remove all nodes between start and end comment markers (preserves the markers). */
65
+ function removeElementsBetweenMarkers(start: Comment, end: Comment): void {
66
+ let node: Node | null = start.nextSibling
67
+ while (node && node !== end) {
68
+ const next: Node | null = node.nextSibling
69
+ node.parentNode?.removeChild(node)
70
+ node = next
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Get loop children from a container, respecting bf-loop boundary markers.
76
+ * When markers are present, returns only elements between them.
77
+ * When absent, returns all children (backward compatible).
78
+ * Exported for use by compiler-generated hydration code.
79
+ */
80
+ export function getLoopChildren(container: HTMLElement, markerId?: string): HTMLElement[] {
81
+ const { startMarker, endMarker } = findLoopMarkers(container, markerId)
82
+ if (startMarker && endMarker) {
83
+ return getElementsBetweenMarkers(startMarker, endMarker) as HTMLElement[]
84
+ }
85
+ return Array.from(container.children) as HTMLElement[]
86
+ }
87
+
88
+ /**
89
+ * Like {@link getLoopChildren}, but returns every node between the loop
90
+ * boundary markers — Comments (per-item `<!--bf-loop-i-->` markers) and
91
+ * text included. The branch-clearing path needs to remove the per-item
92
+ * marker comments alongside elements; otherwise stale markers would
93
+ * accumulate when a branch swap forces mapArray to start over (#1212).
94
+ */
95
+ export function getLoopNodes(container: HTMLElement, markerId?: string): Node[] {
96
+ const { startMarker, endMarker } = findLoopMarkers(container, markerId)
97
+ const nodes: Node[] = []
98
+ if (startMarker && endMarker) {
99
+ let node: Node | null = startMarker.nextSibling
100
+ while (node && node !== endMarker) {
101
+ nodes.push(node)
102
+ node = node.nextSibling
103
+ }
104
+ return nodes
105
+ }
106
+ return Array.from(container.childNodes)
107
+ }
108
+
109
+ /**
110
+ * Ensure loop boundary markers exist in a container for SSR-rendered content.
111
+ * SSR HTML doesn't include markers, so we insert them during hydration.
112
+ * Uses itemCount to identify the last N children as loop items (rest are siblings).
113
+ */
114
+ export function ensureLoopMarkers(container: HTMLElement, itemCount: number, markerId?: string): void {
115
+ // Already has markers
116
+ const { startMarker } = findLoopMarkers(container, markerId)
117
+ if (startMarker) return
118
+
119
+ const children = Array.from(container.children)
120
+ if (children.length === 0) return
121
+
122
+ // Loop items are the LAST itemCount children (siblings come first in HTML order)
123
+ const loopStartIdx = Math.max(0, children.length - itemCount)
124
+ const firstLoopChild = children[loopStartIdx]
125
+
126
+ const start = document.createComment(markerId ? loopStartMarker(markerId) : BF_LOOP_START)
127
+ const end = document.createComment(markerId ? loopEndMarker(markerId) : BF_LOOP_END)
128
+ container.insertBefore(start, firstLoopChild)
129
+ container.appendChild(end)
130
+ }
131
+
132
+ /**
133
+ * Reconcile a list container using HTMLElement mode (for createComponent).
134
+ * Reuses existing elements by key, creates new elements as needed.
135
+ *
136
+ * @param container - The parent element containing list items
137
+ * @param items - Array of items to render
138
+ * @param getKey - Function to extract a unique key from each item (or null to use index)
139
+ * @param renderItem - Function that returns an HTMLElement for each item
140
+ * @param firstElement - Pre-created element for first item (avoids duplicate creation when caller already rendered item 0)
141
+ */
142
+ export function reconcileElements<T>(
143
+ container: HTMLElement | null,
144
+ items: T[],
145
+ getKey: ((item: T, index: number) => string) | null,
146
+ renderItem: (item: T, index: number) => HTMLElement,
147
+ firstElement?: HTMLElement,
148
+ markerId?: string,
149
+ ): void {
150
+ if (!container || !items) return
151
+
152
+ // Find loop boundary markers if present.
153
+ // When markers exist, only elements between <!--bf-loop--> and <!--/bf-loop-->
154
+ // participate in reconciliation — siblings outside the range are preserved.
155
+ const { startMarker, endMarker } = findLoopMarkers(container, markerId)
156
+
157
+ // Collect existing keyed elements (only within loop range if markers exist)
158
+ const existingByKey = new Map<string, HTMLElement>()
159
+ let hasKeyedChildren = false
160
+ const loopChildren = startMarker
161
+ ? getElementsBetweenMarkers(startMarker, endMarker!)
162
+ : Array.from(container.children)
163
+ for (const child of loopChildren) {
164
+ const el = child as HTMLElement
165
+ const key = el.dataset?.key
166
+ if (key !== undefined) {
167
+ existingByKey.set(key, el)
168
+ hasKeyedChildren = true
169
+ }
170
+ }
171
+
172
+ // When no keyed children exist (initial SSR render or all-unkeyed container),
173
+ // use the simple clear-and-replace path. Non-keyed children in this case are
174
+ // SSR-rendered loop items that haven't been through hydration yet.
175
+ if (!hasKeyedChildren) {
176
+ if (items.length === 0) {
177
+ if (startMarker) {
178
+ removeElementsBetweenMarkers(startMarker, endMarker!)
179
+ } else {
180
+ container.innerHTML = ''
181
+ }
182
+ return
183
+ }
184
+
185
+ const fragment = document.createDocumentFragment()
186
+ for (let i = 0; i < items.length; i++) {
187
+ const el = (i === 0 && firstElement) ? firstElement : renderItem(items[i], i)
188
+ const key = getKey ? getKey(items[i], i) : String(i)
189
+ if (!el.dataset.key) el.setAttribute(BF_KEY, key)
190
+ fragment.appendChild(el)
191
+ }
192
+ if (startMarker) {
193
+ removeElementsBetweenMarkers(startMarker, endMarker!)
194
+ endMarker!.parentNode!.insertBefore(fragment, endMarker)
195
+ } else {
196
+ container.innerHTML = ''
197
+ container.appendChild(fragment)
198
+ }
199
+ return
200
+ }
201
+
202
+ // Insert anchor: end marker (if present) or first non-keyed sibling after keyed region.
203
+ let insertAnchor: Node | null = endMarker ?? null
204
+ if (!startMarker) {
205
+ let foundKeyed = false
206
+ for (const child of Array.from(container.childNodes)) {
207
+ if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).dataset.key !== undefined) {
208
+ foundKeyed = true
209
+ } else if (foundKeyed) {
210
+ insertAnchor = child
211
+ break
212
+ }
213
+ }
214
+ }
215
+
216
+ // --- Phase 1: Detect focus (before ANY DOM mutation) ---
217
+ // Only text inputs have ongoing user state (cursor, selection, typed text)
218
+ // that must survive reconciliation. Button focus has no state to preserve.
219
+ let focusedKey: string | null = null
220
+ const activeEl = document.activeElement
221
+ if (activeEl && activeEl !== document.body) {
222
+ const tag = activeEl.tagName
223
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
224
+ || (activeEl as HTMLElement).isContentEditable) {
225
+ for (const [key, el] of existingByKey) {
226
+ if (el.contains(activeEl)) {
227
+ focusedKey = key
228
+ break
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // --- Phase 2: Build desired element list ---
235
+ // For each item, decide: reuse existing (focus), create new, or skip.
236
+ // Track old elements to remove explicitly — no bulk remove-all.
237
+ const desiredElements: HTMLElement[] = []
238
+ const toRemove: Element[] = []
239
+ let focusTarget: FocusTransferInfo | null = null
240
+
241
+ for (let i = 0; i < items.length; i++) {
242
+ const item = items[i]
243
+ const key = getKey ? getKey(item, i) : String(i)
244
+ const createEl = () => (i === 0 && firstElement) ? firstElement : renderItem(item, i)
245
+
246
+ const existing = existingByKey.get(key)
247
+ if (existing) {
248
+ existingByKey.delete(key)
249
+
250
+ if (existing.getAttribute(BF_SCOPE) && !hydratedScopes.has(existing)) {
251
+ // Uninitialized SSR element — replace with client-rendered element
252
+ const newEl = createEl()
253
+ if (!newEl.dataset.key) newEl.setAttribute(BF_KEY, key)
254
+ desiredElements.push(newEl)
255
+ toRemove.push(existing)
256
+ } else if (focusedKey === key) {
257
+ // Element contains a focused text input. Create the new element (with
258
+ // updated inner loops, conditionals, etc.), copy input state now,
259
+ // defer focus() to after DOM insertion to avoid flicker.
260
+ const newEl = createEl()
261
+ if (!newEl.dataset.key) newEl.setAttribute(BF_KEY, key)
262
+ focusTarget = prepareInputTransfer(existing, newEl)
263
+ desiredElements.push(newEl)
264
+ toRemove.push(existing)
265
+ } else {
266
+ // Normal update — use new element
267
+ const newEl = createEl()
268
+ if (!newEl.dataset.key) newEl.setAttribute(BF_KEY, key)
269
+ desiredElements.push(newEl)
270
+ toRemove.push(existing)
271
+ }
272
+ } else {
273
+ // Brand new key
274
+ const el = createEl()
275
+ if (!el.dataset.key) el.setAttribute(BF_KEY, key)
276
+ desiredElements.push(el)
277
+ }
278
+ }
279
+
280
+ // Remaining entries in existingByKey are orphans (key no longer in items)
281
+ for (const el of existingByKey.values()) {
282
+ toRemove.push(el)
283
+ }
284
+
285
+ // --- Phase 3: Remove old elements ---
286
+ for (const el of toRemove) {
287
+ if (el.parentNode) el.remove()
288
+ }
289
+
290
+ // --- Phase 4: Insert/move desired elements in correct order ---
291
+ // insertBefore moves already-connected elements; inserts new ones.
292
+ for (const el of desiredElements) {
293
+ container.insertBefore(el, insertAnchor)
294
+ }
295
+
296
+ // --- Phase 5: Restore focus synchronously (element is now in DOM) ---
297
+ if (focusTarget) {
298
+ focusTarget.target.focus()
299
+ if (typeof focusTarget.selectionStart === 'number') {
300
+ focusTarget.target.selectionStart = focusTarget.selectionStart
301
+ focusTarget.target.selectionEnd = focusTarget.selectionEnd
302
+ }
303
+ }
304
+ }
305
+
306
+ interface FocusTransferInfo {
307
+ target: HTMLInputElement
308
+ selectionStart: number | null
309
+ selectionEnd: number | null
310
+ }
311
+
312
+ /**
313
+ * Prepare focus transfer: copy value + selection state from old focused input
314
+ * to the matching input in newEl. Returns info needed to call focus() later
315
+ * (after the new element is inserted into the DOM).
316
+ */
317
+ function prepareInputTransfer(oldEl: HTMLElement, newEl: HTMLElement): FocusTransferInfo | null {
318
+ const focused = oldEl.contains(document.activeElement) ? document.activeElement as HTMLInputElement : null
319
+ if (!focused) return null
320
+
321
+ const tag = focused.tagName
322
+ const oldInputs = Array.from(oldEl.querySelectorAll(tag))
323
+ const idx = oldInputs.indexOf(focused)
324
+ if (idx < 0) return null
325
+
326
+ const newInputs = Array.from(newEl.querySelectorAll(tag)) as HTMLInputElement[]
327
+ const target = newInputs[idx]
328
+ if (!target) return null
329
+
330
+ target.value = focused.value
331
+ return {
332
+ target,
333
+ selectionStart: focused.selectionStart,
334
+ selectionEnd: focused.selectionEnd,
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Sync reactive DOM state from a source element to a target element.
340
+ * Copies class names, replaces conditional elements, and syncs text content.
341
+ */
342
+ export function syncElementState(target: HTMLElement, source: HTMLElement): void {
343
+ // Sync class list (for reactive classes like 'done' on TodoItem)
344
+ target.className = source.className
345
+
346
+ // First, sync conditional elements by replacing them entirely
347
+ const sourceCondSlots = Array.from(source.querySelectorAll(`[${BF_COND}]`))
348
+ for (const sourceCondSlot of sourceCondSlots) {
349
+ const condId = (sourceCondSlot as HTMLElement).getAttribute(BF_COND)
350
+ if (condId) {
351
+ const targetCondSlot = target.querySelector(`[${BF_COND}="${condId}"]`)
352
+ if (targetCondSlot) {
353
+ targetCondSlot.replaceWith(sourceCondSlot)
354
+ }
355
+ }
356
+ }
357
+
358
+ // Then sync text content of bf slots that are NOT inside conditional elements.
359
+ // Use querySelectorAll on BOTH source and target, then match by position index
360
+ // within each slot ID group. This handles multiple component instances that share
361
+ // the same internal slot ID (e.g., multiple Badge components each with bf="s0").
362
+ const sourceSlots = source.querySelectorAll(`[${BF_SLOT}]`)
363
+ const targetSlotsByID = new Map<string, Element[]>()
364
+ const targetAllSlots = target.querySelectorAll(`[${BF_SLOT}]`)
365
+ for (const targetSlot of Array.from(targetAllSlots)) {
366
+ const id = (targetSlot as HTMLElement).getAttribute(BF_SLOT)
367
+ if (id) {
368
+ if (!targetSlotsByID.has(id)) targetSlotsByID.set(id, [])
369
+ targetSlotsByID.get(id)!.push(targetSlot)
370
+ }
371
+ }
372
+
373
+ // Track which index we're at for each slot ID
374
+ const slotIndexCounters = new Map<string, number>()
375
+
376
+ for (const sourceSlot of Array.from(sourceSlots)) {
377
+ const slotId = (sourceSlot as HTMLElement).getAttribute(BF_SLOT)
378
+ if (slotId) {
379
+ if (sourceSlot.closest(`[${BF_COND}]`)) continue
380
+ const idx = slotIndexCounters.get(slotId) ?? 0
381
+ slotIndexCounters.set(slotId, idx + 1)
382
+ const targets = targetSlotsByID.get(slotId)
383
+ const targetSlot = targets?.[idx]
384
+ if (targetSlot && sourceSlot.textContent !== null) {
385
+ if (sourceSlot.children.length === 0) {
386
+ targetSlot.textContent = sourceSlot.textContent
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * BarefootJS - Component Registry
3
+ *
4
+ * Component registry for parent-child communication.
5
+ * Each component registers its init function so parents can initialize children with props.
6
+ */
7
+
8
+ import { BF_SCOPE, BF_HOST } from '@barefootjs/shared'
9
+ import { hydratedScopes } from './hydration-state'
10
+ import { setCurrentScope } from './context'
11
+ import { createComponent } from './component'
12
+ import { findSsrScopeBySlotIn, buildSlotInfo } from './slot-resolver'
13
+ import type { InitFn } from './types'
14
+
15
+ /**
16
+ * Component registry for parent-child communication.
17
+ */
18
+ const componentRegistry = new Map<string, InitFn>()
19
+
20
+ /**
21
+ * Queue of pending child initializations waiting for components to register.
22
+ * Key: component name, Value: array of pending init requests
23
+ */
24
+ const pendingChildInits = new Map<string, Array<{ scope: Element; props: Record<string, unknown> }>>()
25
+
26
+ /**
27
+ * Register a component's init function for parent initialization.
28
+ * Also processes any pending child initializations for this component.
29
+ *
30
+ * @param name - Component name (e.g., 'Counter', 'AddTodoForm')
31
+ * @param init - Init function that takes (scope, props)
32
+ */
33
+ export function registerComponent(name: string, init: InitFn): void {
34
+ componentRegistry.set(name, init)
35
+
36
+ // Drain any pending child initializations queued before this component
37
+ // registered. Re-enter through initChild so the same hydratedScopes
38
+ // bookkeeping + currentScope wrapping applies to deferred and immediate
39
+ // calls alike.
40
+ const pending = pendingChildInits.get(name)
41
+ if (pending) {
42
+ pendingChildInits.delete(name)
43
+ for (const { scope, props } of pending) {
44
+ initChild(name, scope, props)
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Get a component's init function from the registry.
51
+ * Used by createComponent() to initialize dynamically created components.
52
+ *
53
+ * @param name - Component name
54
+ * @returns Init function or undefined if not registered
55
+ */
56
+ export function getComponentInit(name: string): InitFn | undefined {
57
+ return componentRegistry.get(name)
58
+ }
59
+
60
+ /**
61
+ * Initialize a child component with props from parent.
62
+ * Used by parent components to pass function props (like onAdd) to children.
63
+ *
64
+ * If the child component's script hasn't loaded yet (component not registered),
65
+ * queues the initialization request. When the component registers via
66
+ * registerComponent(), pending initializations are processed synchronously.
67
+ *
68
+ * @param name - Child component name
69
+ * @param childScope - The child's scope element (found by parent)
70
+ * @param props - Props to pass to the child (including function props)
71
+ */
72
+ export function initChild(
73
+ name: string,
74
+ childScope: Element | null,
75
+ props: Record<string, unknown> = {}
76
+ ): void {
77
+ if (!childScope) return
78
+
79
+ const init = componentRegistry.get(name)
80
+ if (!init) {
81
+ // Component not registered yet - queue initialization for when it registers
82
+ // This handles cases where parent script loads before child script
83
+ if (!pendingChildInits.has(name)) {
84
+ pendingChildInits.set(name, [])
85
+ }
86
+ pendingChildInits.get(name)!.push({ scope: childScope, props })
87
+ return
88
+ }
89
+
90
+ // Child scopes are owned by their parent's initChild entirely — once
91
+ // we've run their init, never re-enter. Top-level scopes (no `bf-h`)
92
+ // reach this path through `upsertChild` during reconcile, where
93
+ // re-invoking init is the documented way to deliver fresh
94
+ // closure-captured callback props to the child.
95
+ if (hydratedScopes.has(childScope) && childScope.hasAttribute(BF_HOST)) {
96
+ return
97
+ }
98
+
99
+ const prevScope = setCurrentScope(childScope)
100
+ try {
101
+ init(childScope, props)
102
+ } finally {
103
+ setCurrentScope(prevScope)
104
+ }
105
+
106
+ // Mark the scope as hydrated AFTER init runs so the doc-order walker in
107
+ // hydrate.ts knows to skip this element on its later pass — the parent
108
+ // has just claimed responsibility for it. This is what lets the walker
109
+ // get away with a single `hydratedScopes.has(el)` check instead of an
110
+ // ancestor-name guard.
111
+ hydratedScopes.add(childScope)
112
+ }
113
+
114
+ /**
115
+ * Upsert a child component at a slot inside `parent`. Resolves the SSR vs
116
+ * CSR shape at runtime in one place — so the compiler doesn't need a
117
+ * `mode: 'csr' | 'ssr'` argument for child component emission.
118
+ *
119
+ * 1. SSR: a `[bf-h][bf-m]` element exists for this (parent,
120
+ * slot). Initialise it via initChild and return it.
121
+ * 2. CSR: a `[data-bf-ph="<slotId|name>"]` placeholder exists. Replace it
122
+ * with `createComponent(name, props, key)` and return the new element.
123
+ * 3. Neither matches (already initialised on a previous reconcile pass) —
124
+ * no-op, return null.
125
+ *
126
+ * The returned element is the live component scope element — callers can
127
+ * use it for follow-up effects (e.g. a children-textContent createEffect).
128
+ */
129
+ export function upsertChild(
130
+ parent: Element,
131
+ name: string,
132
+ slotId: string | null,
133
+ props: Record<string, unknown>,
134
+ key?: string | number,
135
+ anchorScope?: Element | null,
136
+ ): HTMLElement | null {
137
+ // SSR: scope element is already in the tree.
138
+ // With slotId: (bf-h, bf-m) primary lookup (unique by construction).
139
+ // Without slotId: name-prefix bf-s scan for top-level component lookup.
140
+ let ssr: HTMLElement | null = null
141
+ if (slotId) {
142
+ ssr = findSsrScopeBySlotIn(parent, slotId, anchorScope, /* selfMatch */ false)
143
+ } else {
144
+ ssr = parent.querySelector(`[${BF_SCOPE}^="${name}_"]`) as HTMLElement | null
145
+ }
146
+ if (ssr) {
147
+ initChild(name, ssr, props)
148
+ return ssr
149
+ }
150
+ // CSR: replace placeholder with a freshly-created component.
151
+ const phId = slotId ?? name
152
+ const ph = parent.querySelector(`[data-bf-ph="${phId}"]`) as HTMLElement | null
153
+ if (ph) {
154
+ const slot = slotId ? buildSlotInfo(parent, slotId, anchorScope) : undefined
155
+ const comp = createComponent(name, props, key, slot)
156
+ ph.replaceWith(comp)
157
+ return comp
158
+ }
159
+ return null
160
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * BarefootJS - Client-Side Rendering
3
+ *
4
+ * CSR entry point for rendering components directly in the browser
5
+ * without server-side rendering. Tree-shakeable: SSR-only apps
6
+ * never import this module.
7
+ */
8
+
9
+ import { BF_SCOPE } from '@barefootjs/shared'
10
+ import { setParentScopeId } from './component'
11
+ import { hydratedScopes } from './hydration-state'
12
+ import { getComponentInit } from './registry'
13
+ import { getTemplate, type TemplateFn } from './template'
14
+ import type { ComponentDef, InitFn } from './types'
15
+
16
+ /**
17
+ * Render a component into a container element (CSR mode).
18
+ *
19
+ * Accepts either:
20
+ * - A registered component name (string) — looks up `init` and `template` from the registry
21
+ * (the component must be registered first by importing its `.client.js` file).
22
+ * - A `ComponentDef` — uses the def's `init` and `template` directly, bypassing the registry.
23
+ *
24
+ * Generates DOM from the template, mounts it into the container, and initializes it
25
+ * with the given props. Unlike hydrate(), no pre-rendered HTML is required; the
26
+ * container's content is replaced entirely.
27
+ *
28
+ * @param container - Target DOM element to render into
29
+ * @param nameOrDef - Registered component name or a ComponentDef
30
+ * @param props - Props to pass to the component
31
+ *
32
+ * @example
33
+ * // By name (registry-based)
34
+ * await import('/static/components/Counter.client.js')
35
+ * render(document.getElementById('app')!, 'Counter', { initialCount: 0 })
36
+ *
37
+ * @example
38
+ * // By ComponentDef (registry-free)
39
+ * render(container, { name: 'MyNode', init, template }, { id: 'n1' })
40
+ */
41
+ export function render(
42
+ container: HTMLElement,
43
+ nameOrDef: string | ComponentDef,
44
+ props: Record<string, unknown> = {}
45
+ ): void {
46
+ let name: string
47
+ let init: InitFn | undefined
48
+ let template: TemplateFn | undefined
49
+
50
+ if (typeof nameOrDef === 'string') {
51
+ name = nameOrDef
52
+ init = getComponentInit(name)
53
+ template = getTemplate(name)
54
+
55
+ if (!init || !template) {
56
+ throw new Error(
57
+ `[BarefootJS] Component "${name}" is not registered. ` +
58
+ `Did you import its .client.js file before calling render()?`
59
+ )
60
+ }
61
+ } else {
62
+ init = nameOrDef.init
63
+ template = nameOrDef.template
64
+ name = nameOrDef.name || init.name?.replace(/^init/, '') || 'Component'
65
+
66
+ if (!template) {
67
+ throw new Error(
68
+ '[BarefootJS] render(): ComponentDef requires a template function'
69
+ )
70
+ }
71
+ }
72
+
73
+ // Generate the parent scope ID up front so renderChild calls inside
74
+ // template() can stamp `bf-s="${parentScopeId}_sN"` on child scopes,
75
+ // matching what the compiler-emitted `$c(__scope, 'sN')` lookup later
76
+ // expects. Without this, renderChild falls back to `${childName}_${randomId}`
77
+ // and `$c` returns null, silently breaking child hydration. (#1160)
78
+ const scopeId = `${name}_${Math.random().toString(36).slice(2, 8)}`
79
+ setParentScopeId(scopeId)
80
+ let html: string
81
+ try {
82
+ html = template(props).trim()
83
+ } finally {
84
+ setParentScopeId(null)
85
+ }
86
+
87
+ const tpl = document.createElement('template')
88
+ tpl.innerHTML = html
89
+ const element = tpl.content.firstChild as HTMLElement
90
+
91
+ if (!element) {
92
+ throw new Error('[BarefootJS] render(): template returned empty HTML')
93
+ }
94
+
95
+ if (!element.getAttribute(BF_SCOPE)) {
96
+ element.setAttribute(BF_SCOPE, scopeId)
97
+ }
98
+
99
+ container.innerHTML = ''
100
+ container.appendChild(element)
101
+
102
+ init(element, props)
103
+
104
+ hydratedScopes.add(element)
105
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * BarefootJS - Comment Scope Registry
3
+ *
4
+ * Registry for elements that serve as scope proxies for comment-based scopes.
5
+ * Maps an element to its comment node and the sibling range boundary.
6
+ */
7
+
8
+ import { BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
9
+
10
+ /**
11
+ * Information about a comment-based scope.
12
+ */
13
+ export interface CommentScopeInfo {
14
+ commentNode: Comment
15
+ scopeId: string
16
+ }
17
+
18
+ /**
19
+ * Registry mapping elements to their comment scope info.
20
+ */
21
+ export const commentScopeRegistry = new WeakMap<Element, CommentScopeInfo>()
22
+
23
+ /**
24
+ * Get the scope ID for an element from the comment scope registry.
25
+ * Used by createPortal to resolve scope IDs for comment-based scopes.
26
+ */
27
+ export function getPortalScopeId(element: Element): string | null {
28
+ const info = commentScopeRegistry.get(element)
29
+ return info?.scopeId ?? null
30
+ }
31
+
32
+ /**
33
+ * Find the end boundary for a comment-based scope.
34
+ * The boundary is the next bf-scope: comment or the end of the parent's children.
35
+ */
36
+ export function getCommentScopeBoundary(commentNode: Comment): Node | null {
37
+ let node: Node | null = commentNode.nextSibling
38
+ while (node) {
39
+ if (node.nodeType === Node.COMMENT_NODE &&
40
+ (node as Comment).nodeValue?.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
41
+ return node
42
+ }
43
+ node = node.nextSibling
44
+ }
45
+ return null // End of parent's children
46
+ }