@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,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
|
+
}
|