@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.
- package/dist/build.d.ts +56 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +76 -0
- package/dist/context.d.ts +25 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/csr-adapter.d.ts +26 -0
- package/dist/csr-adapter.d.ts.map +1 -0
- package/dist/forward-props.d.ts +17 -0
- package/dist/forward-props.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +154 -0
- package/dist/reactive.d.ts +150 -0
- package/dist/reactive.d.ts.map +1 -0
- package/dist/reactive.js +215 -0
- package/dist/runtime/apply-rest-attrs.d.ts +16 -0
- package/dist/runtime/apply-rest-attrs.d.ts.map +1 -0
- package/dist/runtime/branch-slot.d.ts +22 -0
- package/dist/runtime/branch-slot.d.ts.map +1 -0
- package/dist/runtime/client-marker.d.ts +21 -0
- package/dist/runtime/client-marker.d.ts.map +1 -0
- package/dist/runtime/component.d.ts +99 -0
- package/dist/runtime/component.d.ts.map +1 -0
- package/dist/runtime/context.d.ts +40 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/hydrate.d.ts +100 -0
- package/dist/runtime/hydrate.d.ts.map +1 -0
- package/dist/runtime/hydration-state.d.ts +13 -0
- package/dist/runtime/hydration-state.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +27 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +2093 -0
- package/dist/runtime/insert.d.ts +75 -0
- package/dist/runtime/insert.d.ts.map +1 -0
- package/dist/runtime/list.d.ts +21 -0
- package/dist/runtime/list.d.ts.map +1 -0
- package/dist/runtime/map-array.d.ts +32 -0
- package/dist/runtime/map-array.d.ts.map +1 -0
- package/dist/runtime/portal.d.ts +96 -0
- package/dist/runtime/portal.d.ts.map +1 -0
- package/dist/runtime/qsa-item.d.ts +52 -0
- package/dist/runtime/qsa-item.d.ts.map +1 -0
- package/dist/runtime/query.d.ts +86 -0
- package/dist/runtime/query.d.ts.map +1 -0
- package/dist/runtime/reconcile-elements.d.ts +44 -0
- package/dist/runtime/reconcile-elements.d.ts.map +1 -0
- package/dist/runtime/registry.d.ts +53 -0
- package/dist/runtime/registry.d.ts.map +1 -0
- package/dist/runtime/render.d.ts +35 -0
- package/dist/runtime/render.d.ts.map +1 -0
- package/dist/runtime/scope.d.ts +28 -0
- package/dist/runtime/scope.d.ts.map +1 -0
- package/dist/runtime/slot-resolver.d.ts +36 -0
- package/dist/runtime/slot-resolver.d.ts.map +1 -0
- package/dist/runtime/spread-attrs.d.ts +19 -0
- package/dist/runtime/spread-attrs.d.ts.map +1 -0
- package/dist/runtime/standalone.js +2278 -0
- package/dist/runtime/streaming.d.ts +36 -0
- package/dist/runtime/streaming.d.ts.map +1 -0
- package/dist/runtime/style.d.ts +17 -0
- package/dist/runtime/style.d.ts.map +1 -0
- package/dist/runtime/template.d.ts +39 -0
- package/dist/runtime/template.d.ts.map +1 -0
- package/dist/runtime/types.d.ts +26 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/shims.d.ts +21 -0
- package/dist/shims.d.ts.map +1 -0
- package/dist/slot.d.ts +14 -0
- package/dist/slot.d.ts.map +1 -0
- package/dist/split-props.d.ts +26 -0
- package/dist/split-props.d.ts.map +1 -0
- package/dist/unwrap.d.ts +16 -0
- package/dist/unwrap.d.ts.map +1 -0
- package/package.json +71 -0
- package/src/build.ts +92 -0
- package/src/context.ts +33 -0
- package/src/csr-adapter.ts +134 -0
- package/src/forward-props.ts +43 -0
- package/src/index.ts +42 -0
- package/src/reactive.ts +411 -0
- package/src/runtime/apply-rest-attrs.ts +109 -0
- package/src/runtime/branch-slot.ts +32 -0
- package/src/runtime/client-marker.ts +46 -0
- package/src/runtime/component.ts +501 -0
- package/src/runtime/context.ts +111 -0
- package/src/runtime/hydrate.ts +311 -0
- package/src/runtime/hydration-state.ts +13 -0
- package/src/runtime/index.ts +96 -0
- package/src/runtime/insert.ts +407 -0
- package/src/runtime/list.ts +47 -0
- package/src/runtime/map-array.ts +381 -0
- package/src/runtime/portal.ts +174 -0
- package/src/runtime/qsa-item.ts +128 -0
- package/src/runtime/query.ts +632 -0
- package/src/runtime/reconcile-elements.ts +391 -0
- package/src/runtime/registry.ts +160 -0
- package/src/runtime/render.ts +105 -0
- package/src/runtime/scope.ts +46 -0
- package/src/runtime/slot-resolver.ts +66 -0
- package/src/runtime/spread-attrs.ts +88 -0
- package/src/runtime/streaming.ts +65 -0
- package/src/runtime/style.ts +27 -0
- package/src/runtime/template.ts +53 -0
- package/src/runtime/types.ts +27 -0
- package/src/shims.ts +54 -0
- package/src/slot.ts +23 -0
- package/src/split-props.ts +86 -0
- 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
|
+
}
|