@barefootjs/client 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/component.d.ts +1 -1
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/dynamic-text.d.ts +20 -0
- package/dist/runtime/dynamic-text.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +292 -26
- package/dist/runtime/insert.d.ts +1 -1
- package/dist/runtime/insert.d.ts.map +1 -1
- package/dist/runtime/map-array.d.ts +9 -0
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/scope.d.ts +7 -1
- package/dist/runtime/scope.d.ts.map +1 -1
- package/dist/runtime/standalone.js +292 -26
- package/package.json +2 -2
- package/src/runtime/component.ts +6 -1
- package/src/runtime/dynamic-text.ts +88 -0
- package/src/runtime/index.ts +2 -1
- package/src/runtime/insert.ts +124 -26
- package/src/runtime/map-array.ts +227 -0
- package/src/runtime/scope.ts +18 -5
package/src/runtime/insert.ts
CHANGED
|
@@ -9,7 +9,83 @@
|
|
|
9
9
|
import { createEffect, untrack } from '@barefootjs/client/reactive'
|
|
10
10
|
import { find } from './query'
|
|
11
11
|
import { setParentScopeId, parseHTML } from './component'
|
|
12
|
-
import {
|
|
12
|
+
import { commentScopeRegistry, getCommentScopeBoundary } from './scope'
|
|
13
|
+
import { BF_COND, BF_SCOPE, BF_LOOP_ITEM } from '@barefootjs/shared'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolved search context for an `insert()` call (#1665).
|
|
17
|
+
*
|
|
18
|
+
* `anchor === null` is the legacy element-scope path: every DOM read goes
|
|
19
|
+
* through the component scope element exactly as before. When `insert()` is
|
|
20
|
+
* given a `<!--bf-loop-i:<key>-->` anchor instead, the conditional is a
|
|
21
|
+
* whole loop item with no wrapper element, so all reads are confined to that
|
|
22
|
+
* item's sibling range — letting every item reuse the same conditional slot
|
|
23
|
+
* id without colliding.
|
|
24
|
+
*/
|
|
25
|
+
interface CondRegion {
|
|
26
|
+
/** The loop-item anchor comment, or `null` for element scopes. */
|
|
27
|
+
anchor: Comment | null
|
|
28
|
+
/** Element handed to `find()`/`$`/`$t`/`bindEvents`. For loop items this
|
|
29
|
+
* is a detached proxy registered in `commentScopeRegistry`, so the shared
|
|
30
|
+
* query machinery walks the item's range via the comment-scope branch. */
|
|
31
|
+
bindScope: Element
|
|
32
|
+
/** Parent component scope id for `setParentScopeId()` / `renderChild`. */
|
|
33
|
+
parentScopeId: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeRegion(scope: Element | Comment): CondRegion {
|
|
37
|
+
if (scope.nodeType === Node.COMMENT_NODE) {
|
|
38
|
+
const anchor = scope as Comment
|
|
39
|
+
const parentEl = anchor.parentElement
|
|
40
|
+
const componentScope = parentEl?.closest(`[${BF_SCOPE}]`) ?? null
|
|
41
|
+
const parentScopeId = componentScope?.getAttribute(BF_SCOPE) ?? null
|
|
42
|
+
// Detached proxy: candidatesInScope() keys off the registered comment
|
|
43
|
+
// node (not the proxy's DOM position), so the proxy need not be mounted.
|
|
44
|
+
const proxyEl = document.createElement('bf-loop-item')
|
|
45
|
+
commentScopeRegistry.set(proxyEl, { commentNode: anchor, scopeId: parentScopeId ?? '' })
|
|
46
|
+
return { anchor, bindScope: proxyEl, parentScopeId }
|
|
47
|
+
}
|
|
48
|
+
const el = scope as Element
|
|
49
|
+
return { anchor: null, bindScope: el, parentScopeId: el.getAttribute(BF_SCOPE) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Find the `[bf-c="id"]` element within a loop item's sibling range. */
|
|
53
|
+
function findCondElInRange(anchor: Comment, id: string): Element | null {
|
|
54
|
+
const sel = `[${BF_COND}="${id}"]`
|
|
55
|
+
const boundary = getCommentScopeBoundary(anchor)
|
|
56
|
+
let node: Node | null = anchor.nextSibling
|
|
57
|
+
while (node && node !== boundary) {
|
|
58
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
59
|
+
const el = node as Element
|
|
60
|
+
if (el.matches?.(sel)) return el
|
|
61
|
+
const inner = el.querySelector(sel)
|
|
62
|
+
if (inner) return inner
|
|
63
|
+
}
|
|
64
|
+
node = node.nextSibling
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Find the `bf-cond-start:id` comment within a loop item's sibling range
|
|
70
|
+
* (checking range siblings and their descendants). */
|
|
71
|
+
function findCondStartInRange(anchor: Comment, id: string): Comment | null {
|
|
72
|
+
const want = `bf-cond-start:${id}`
|
|
73
|
+
const boundary = getCommentScopeBoundary(anchor)
|
|
74
|
+
let node: Node | null = anchor.nextSibling
|
|
75
|
+
while (node && node !== boundary) {
|
|
76
|
+
if (node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === want) {
|
|
77
|
+
return node as Comment
|
|
78
|
+
}
|
|
79
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
80
|
+
const w = document.createTreeWalker(node as Element, NodeFilter.SHOW_COMMENT)
|
|
81
|
+
while (w.nextNode()) {
|
|
82
|
+
if ((w.currentNode as Comment).nodeValue === want) return w.currentNode as Comment
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
node = node.nextSibling
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
13
89
|
|
|
14
90
|
/**
|
|
15
91
|
* Result returned by a branch's `template()` when the template captures
|
|
@@ -102,7 +178,7 @@ function evalBranchTemplate(branch: BranchConfig): BranchTemplateResult {
|
|
|
102
178
|
* @param whenFalse - Branch config for when condition is false
|
|
103
179
|
*/
|
|
104
180
|
export function insert(
|
|
105
|
-
scope: Element | null,
|
|
181
|
+
scope: Element | Comment | null,
|
|
106
182
|
id: string,
|
|
107
183
|
conditionFn: () => boolean,
|
|
108
184
|
whenTrue: BranchConfig,
|
|
@@ -110,10 +186,17 @@ export function insert(
|
|
|
110
186
|
): void {
|
|
111
187
|
if (!scope) return
|
|
112
188
|
|
|
189
|
+
// Resolve the scope into a search region. For an Element scope this is
|
|
190
|
+
// byte-identical to the legacy descendant search. For a Comment anchor
|
|
191
|
+
// (`<!--bf-loop-i:<key>-->`) the region is the item's sibling range, so a
|
|
192
|
+
// whole-item conditional toggles only its own item even when every item
|
|
193
|
+
// shares the same conditional slot id (#1665).
|
|
194
|
+
const region = makeRegion(scope)
|
|
195
|
+
|
|
113
196
|
// Extract parent scope ID for renderChild context.
|
|
114
197
|
// When branch templates call renderChild(), it needs the parent scope ID
|
|
115
198
|
// so child mounts can stamp `bf-h` / `bf-m` for slot-resolver lookups.
|
|
116
|
-
const parentScopeId =
|
|
199
|
+
const parentScopeId = region.parentScopeId
|
|
117
200
|
|
|
118
201
|
// Check if either branch uses fragment conditional (comment markers).
|
|
119
202
|
// Both branches need to be checked because SSR may render either branch.
|
|
@@ -166,7 +249,9 @@ export function insert(
|
|
|
166
249
|
setParentScopeId(parentScopeId)
|
|
167
250
|
let result: BranchTemplateResult
|
|
168
251
|
try { result = evalBranchTemplate(branch) } finally { setParentScopeId(null) }
|
|
169
|
-
const existingEl =
|
|
252
|
+
const existingEl = region.anchor
|
|
253
|
+
? findCondElInRange(region.anchor, id)
|
|
254
|
+
: find(region.bindScope, `[${BF_COND}="${id}"]`)
|
|
170
255
|
if (existingEl) {
|
|
171
256
|
// Compare full opening tag signatures to detect branch mismatch.
|
|
172
257
|
// Tag-name-only comparison fails when both branches use the same tag (e.g., <div>).
|
|
@@ -182,32 +267,32 @@ export function insert(
|
|
|
182
267
|
// branch — the SSR DOM is correct, and replacing it would re-render
|
|
183
268
|
// via the registered child template, which doesn't reproduce the
|
|
184
269
|
// bf-h / bf-m markers set by the parent's JSX scope chain.
|
|
185
|
-
updateFragmentConditional(
|
|
270
|
+
updateFragmentConditional(region, id, result)
|
|
186
271
|
} else if (!isFragmentCond && expectedSig && existingSig && expectedSig !== existingSig) {
|
|
187
272
|
// DOM doesn't match expected branch - need to swap
|
|
188
|
-
updateElementConditional(
|
|
273
|
+
updateElementConditional(region, id, result)
|
|
189
274
|
} else if (result.slots.length > 0) {
|
|
190
275
|
// Branch template captured live nodes via __bfSlot (#1213). The
|
|
191
276
|
// SSR DOM rendered Hono-stringified HTML, but the client now needs
|
|
192
277
|
// the live signal-bound nodes installed. Force a swap so the
|
|
193
278
|
// existing element is replaced with the slot-spliced template.
|
|
194
|
-
updateElementConditional(
|
|
279
|
+
updateElementConditional(region, id, result)
|
|
195
280
|
}
|
|
196
281
|
} else if (isFragmentCond) {
|
|
197
282
|
// For @client fragment conditionals, SSR renders only comment markers.
|
|
198
283
|
// We need to insert the actual content on first run.
|
|
199
|
-
updateFragmentConditional(
|
|
284
|
+
updateFragmentConditional(region, id, result)
|
|
200
285
|
}
|
|
201
286
|
|
|
202
287
|
// Bind events to the (possibly updated) SSR element. Pass isFirstRun
|
|
203
288
|
// so branch composite loops can skip the wipe-then-rebuild path that
|
|
204
289
|
// is only needed for subsequent branch swaps (the SSR-rendered DOM
|
|
205
290
|
// already matches the data and mapArray reconciles by key from it).
|
|
206
|
-
const cleanup = branch.bindEvents(
|
|
291
|
+
const cleanup = branch.bindEvents(region.bindScope, { isFirstRun: true })
|
|
207
292
|
branchCleanup = typeof cleanup === 'function' ? cleanup : null
|
|
208
293
|
|
|
209
294
|
// Auto-focus on first run too (for components created via createComponent with editing=true)
|
|
210
|
-
autoFocusConditionalElement(
|
|
295
|
+
autoFocusConditionalElement(region, id)
|
|
211
296
|
return
|
|
212
297
|
}
|
|
213
298
|
|
|
@@ -229,17 +314,17 @@ export function insert(
|
|
|
229
314
|
let result: BranchTemplateResult
|
|
230
315
|
try { result = evalBranchTemplate(branch) } finally { setParentScopeId(null) }
|
|
231
316
|
if (isFragmentCond) {
|
|
232
|
-
updateFragmentConditional(
|
|
317
|
+
updateFragmentConditional(region, id, result)
|
|
233
318
|
} else {
|
|
234
|
-
updateElementConditional(
|
|
319
|
+
updateElementConditional(region, id, result)
|
|
235
320
|
}
|
|
236
321
|
|
|
237
322
|
// Bind events to the newly inserted element (branch swap: not first run).
|
|
238
|
-
const cleanup = branch.bindEvents(
|
|
323
|
+
const cleanup = branch.bindEvents(region.bindScope, { isFirstRun: false })
|
|
239
324
|
branchCleanup = typeof cleanup === 'function' ? cleanup : null
|
|
240
325
|
|
|
241
326
|
// Auto-focus elements with autofocus attribute (for dynamically created elements)
|
|
242
|
-
autoFocusConditionalElement(
|
|
327
|
+
autoFocusConditionalElement(region, id)
|
|
243
328
|
})
|
|
244
329
|
}
|
|
245
330
|
|
|
@@ -249,12 +334,14 @@ export function insert(
|
|
|
249
334
|
* Used by insert() to focus inputs when they become visible.
|
|
250
335
|
* Uses requestAnimationFrame to ensure element is in DOM before focusing.
|
|
251
336
|
*/
|
|
252
|
-
function autoFocusConditionalElement(
|
|
337
|
+
function autoFocusConditionalElement(region: CondRegion, id: string): void {
|
|
253
338
|
// Use requestAnimationFrame to defer focus until after DOM updates.
|
|
254
339
|
// This is necessary because createComponent() may call insert() before
|
|
255
340
|
// the element is added to the document by reconcileList().
|
|
256
341
|
requestAnimationFrame(() => {
|
|
257
|
-
const condEl =
|
|
342
|
+
const condEl = region.anchor
|
|
343
|
+
? findCondElInRange(region.anchor, id)
|
|
344
|
+
: region.bindScope.querySelector(`[${BF_COND}="${id}"]`)
|
|
258
345
|
if (condEl) {
|
|
259
346
|
const autofocusEl = condEl.matches('[autofocus]')
|
|
260
347
|
? condEl
|
|
@@ -307,20 +394,29 @@ function spliceSlots(fragment: DocumentFragment, slots: Node[]): DocumentFragmen
|
|
|
307
394
|
/**
|
|
308
395
|
* Update fragment conditional (content between comment markers)
|
|
309
396
|
*/
|
|
310
|
-
function updateFragmentConditional(
|
|
397
|
+
function updateFragmentConditional(region: CondRegion, id: string, result: BranchTemplateResult): void {
|
|
311
398
|
const { html, slots } = result
|
|
312
|
-
|
|
399
|
+
const scope = region.bindScope
|
|
400
|
+
// Find start comment marker. For a loop-item region the marker lives
|
|
401
|
+
// among the anchor's range siblings (no descendant relationship to a
|
|
402
|
+
// scope element), so locate it within the item range (#1665).
|
|
313
403
|
const startMarker = `bf-cond-start:${id}`
|
|
314
404
|
let startComment: Comment | null = null
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
405
|
+
if (region.anchor) {
|
|
406
|
+
startComment = findCondStartInRange(region.anchor, id)
|
|
407
|
+
} else {
|
|
408
|
+
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_COMMENT)
|
|
409
|
+
while (walker.nextNode()) {
|
|
410
|
+
if (walker.currentNode.nodeValue === startMarker) {
|
|
411
|
+
startComment = walker.currentNode as Comment
|
|
412
|
+
break
|
|
413
|
+
}
|
|
320
414
|
}
|
|
321
415
|
}
|
|
322
416
|
|
|
323
|
-
const condEl =
|
|
417
|
+
const condEl = region.anchor
|
|
418
|
+
? findCondElInRange(region.anchor, id)
|
|
419
|
+
: scope.querySelector(`[${BF_COND}="${id}"]`)
|
|
324
420
|
|
|
325
421
|
const endMarker = `bf-cond-end:${id}`
|
|
326
422
|
|
|
@@ -388,8 +484,10 @@ function updateFragmentConditional(scope: Element, id: string, result: BranchTem
|
|
|
388
484
|
/**
|
|
389
485
|
* Update element conditional (single element with bf-c)
|
|
390
486
|
*/
|
|
391
|
-
function updateElementConditional(
|
|
392
|
-
const condEl =
|
|
487
|
+
function updateElementConditional(region: CondRegion, id: string, result: BranchTemplateResult): void {
|
|
488
|
+
const condEl = region.anchor
|
|
489
|
+
? findCondElInRange(region.anchor, id)
|
|
490
|
+
: region.bindScope.querySelector(`[${BF_COND}="${id}"]`)
|
|
393
491
|
if (!condEl) return
|
|
394
492
|
|
|
395
493
|
const { html, slots } = result
|
package/src/runtime/map-array.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
BF_LOOP_ITEM,
|
|
28
28
|
loopStartMarker,
|
|
29
29
|
loopEndMarker,
|
|
30
|
+
loopItemMarker,
|
|
30
31
|
} from '@barefootjs/shared'
|
|
31
32
|
|
|
32
33
|
type ItemScope<T> = {
|
|
@@ -390,3 +391,229 @@ export function mapArray<T>(
|
|
|
390
391
|
}
|
|
391
392
|
})
|
|
392
393
|
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Anchored list rendering (#1665) — whole-item loop conditionals.
|
|
397
|
+
//
|
|
398
|
+
// `arr.map(t => cond(t) && <li/>)` makes the conditional the entire loop
|
|
399
|
+
// item, so an item renders 0-or-1 element per pass. The legacy `mapArray`
|
|
400
|
+
// tracks each item by a required `primaryEl` Element, which cannot represent
|
|
401
|
+
// the empty (false-branch) item. `mapArrayAnchored` instead tracks each item
|
|
402
|
+
// by a `<!--bf-loop-i:KEY-->` anchor comment that is ALWAYS present. The
|
|
403
|
+
// item's content lives between its anchor and the next anchor / loop end and
|
|
404
|
+
// is derived from the live DOM range every pass (never cached): `insert()`
|
|
405
|
+
// owns the content, `mapArrayAnchored` owns the anchors (identity + order).
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
/** Item tracked by its always-present `bf-loop-i:KEY` anchor comment. */
|
|
409
|
+
type AnchorScope<T> = {
|
|
410
|
+
anchor: Comment
|
|
411
|
+
/** Detached nodes to mount for a freshly created (CSR) item; null once mounted. */
|
|
412
|
+
pending: DocumentFragment | null
|
|
413
|
+
dispose: () => void
|
|
414
|
+
setItem: (v: T) => void
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const ITEM_PREFIX = `${BF_LOOP_ITEM}:`
|
|
418
|
+
|
|
419
|
+
function isItemAnchor(node: Node): node is Comment {
|
|
420
|
+
return (
|
|
421
|
+
node.nodeType === Node.COMMENT_NODE &&
|
|
422
|
+
((node as Comment).nodeValue ?? '').startsWith(ITEM_PREFIX)
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Collect a live item range: the anchor and every node up to the next item
|
|
427
|
+
* anchor or the loop end marker (exclusive). Recomputed from the live DOM on
|
|
428
|
+
* every call rather than cached, because `insert()` adds and removes the
|
|
429
|
+
* item's content independently of this module — a cached node list would go
|
|
430
|
+
* stale the moment a conditional toggled. */
|
|
431
|
+
function collectAnchorRange(anchor: Comment, end: Comment | null): Node[] {
|
|
432
|
+
const nodes: Node[] = [anchor]
|
|
433
|
+
let node: Node | null = anchor.nextSibling
|
|
434
|
+
while (node && node !== end) {
|
|
435
|
+
if (isItemAnchor(node)) break
|
|
436
|
+
nodes.push(node)
|
|
437
|
+
node = node.nextSibling
|
|
438
|
+
}
|
|
439
|
+
return nodes
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Partition the loop range into the item anchors present in SSR/CSR DOM. */
|
|
443
|
+
function findItemAnchors(start: Comment, end: Comment): Comment[] {
|
|
444
|
+
const anchors: Comment[] = []
|
|
445
|
+
let node: Node | null = start.nextSibling
|
|
446
|
+
while (node && node !== end) {
|
|
447
|
+
if (isItemAnchor(node)) anchors.push(node as Comment)
|
|
448
|
+
node = node.nextSibling
|
|
449
|
+
}
|
|
450
|
+
return anchors
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Move (or first-mount) a scope's whole range immediately before `before`. */
|
|
454
|
+
function placeAnchorScope<T>(
|
|
455
|
+
scope: AnchorScope<T>,
|
|
456
|
+
container: HTMLElement,
|
|
457
|
+
before: Node | null,
|
|
458
|
+
end: Comment | null,
|
|
459
|
+
): void {
|
|
460
|
+
if (scope.pending) {
|
|
461
|
+
container.insertBefore(scope.pending, before)
|
|
462
|
+
scope.pending = null
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
for (const node of collectAnchorRange(scope.anchor, end)) {
|
|
466
|
+
container.insertBefore(node, before)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Detach a scope's whole range from the DOM. */
|
|
471
|
+
function removeAnchorScope<T>(scope: AnchorScope<T>, end: Comment | null): void {
|
|
472
|
+
for (const node of collectAnchorRange(scope.anchor, end)) {
|
|
473
|
+
node.parentNode?.removeChild(node)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function createAnchorScope<T>(
|
|
478
|
+
item: T,
|
|
479
|
+
index: number,
|
|
480
|
+
key: string,
|
|
481
|
+
renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
|
|
482
|
+
existingAnchor?: Comment,
|
|
483
|
+
): AnchorScope<T> {
|
|
484
|
+
let dispose!: () => void
|
|
485
|
+
let setItem!: (v: T) => void
|
|
486
|
+
let returned!: DocumentFragment | Comment
|
|
487
|
+
|
|
488
|
+
createRoot((d) => {
|
|
489
|
+
dispose = d
|
|
490
|
+
const [itemAccessor, itemSetter] = createSignal(item)
|
|
491
|
+
setItem = itemSetter
|
|
492
|
+
returned = renderItem(itemAccessor, index, existingAnchor)
|
|
493
|
+
return undefined
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
if (existingAnchor) {
|
|
497
|
+
return { anchor: existingAnchor, pending: null, dispose, setItem }
|
|
498
|
+
}
|
|
499
|
+
// CSR: renderItem returns a fragment whose first child is the anchor.
|
|
500
|
+
const frag = returned as DocumentFragment
|
|
501
|
+
const anchor = frag.firstChild as Comment
|
|
502
|
+
// The renderItem already encodes the key into the anchor value, but tolerate
|
|
503
|
+
// a bare anchor by stamping it here so identity/order reads stay consistent.
|
|
504
|
+
if (anchor && !anchor.nodeValue?.startsWith(ITEM_PREFIX)) {
|
|
505
|
+
anchor.nodeValue = loopItemMarker(key)
|
|
506
|
+
}
|
|
507
|
+
return { anchor, pending: frag, dispose, setItem }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Per-item scoped list rendering for whole-item conditionals (#1665).
|
|
512
|
+
*
|
|
513
|
+
* Same call shape as `mapArray`, but `renderItem` returns a `DocumentFragment`
|
|
514
|
+
* (CSR, first child = `bf-loop-i:KEY` anchor) or the existing anchor Comment
|
|
515
|
+
* (hydration). Items may render zero elements; the anchor is the stable
|
|
516
|
+
* identity and position.
|
|
517
|
+
*/
|
|
518
|
+
export function mapArrayAnchored<T>(
|
|
519
|
+
accessor: () => T[],
|
|
520
|
+
container: HTMLElement | null,
|
|
521
|
+
getKey: ((item: T, index: number) => string) | null,
|
|
522
|
+
renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
|
|
523
|
+
markerId?: string,
|
|
524
|
+
): void {
|
|
525
|
+
if (!container) return
|
|
526
|
+
|
|
527
|
+
const scopes = new Map<string, AnchorScope<T>>()
|
|
528
|
+
let hydrated = false
|
|
529
|
+
|
|
530
|
+
createEffect(() => {
|
|
531
|
+
const items = accessor()
|
|
532
|
+
if (!items) return
|
|
533
|
+
|
|
534
|
+
const { start, end } = findLoopMarkers(container, markerId)
|
|
535
|
+
if (!start || !end) return
|
|
536
|
+
|
|
537
|
+
// --- First run: adopt / hydrate SSR-rendered item anchors ---
|
|
538
|
+
if (!hydrated) {
|
|
539
|
+
hydrated = true
|
|
540
|
+
const existing = findItemAnchors(start, end)
|
|
541
|
+
if (existing.length > 0 && scopes.size === 0) {
|
|
542
|
+
for (let i = 0; i < existing.length && i < items.length; i++) {
|
|
543
|
+
const item = items[i]
|
|
544
|
+
const key = getKey ? getKey(item, i) : String(i)
|
|
545
|
+
scopes.set(key, createAnchorScope(item, i, key, renderItem, existing[i]))
|
|
546
|
+
}
|
|
547
|
+
// SSR rendered fewer items than the current array — create the rest.
|
|
548
|
+
for (let i = existing.length; i < items.length; i++) {
|
|
549
|
+
const item = items[i]
|
|
550
|
+
const key = getKey ? getKey(item, i) : String(i)
|
|
551
|
+
const scope = createAnchorScope(item, i, key, renderItem)
|
|
552
|
+
scopes.set(key, scope)
|
|
553
|
+
placeAnchorScope(scope, container, end, end)
|
|
554
|
+
}
|
|
555
|
+
// SSR rendered more items than the current array — drop the extras.
|
|
556
|
+
for (let i = items.length; i < existing.length; i++) {
|
|
557
|
+
for (const node of collectAnchorRange(existing[i], end)) {
|
|
558
|
+
node.parentNode?.removeChild(node)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// --- Key-based diff ---
|
|
566
|
+
const newKeys = new Set<string>()
|
|
567
|
+
const warnedKeys = new Set<string>()
|
|
568
|
+
const desiredOrder: AnchorScope<T>[] = []
|
|
569
|
+
|
|
570
|
+
for (let i = 0; i < items.length; i++) {
|
|
571
|
+
const item = items[i]
|
|
572
|
+
const key = getKey ? getKey(item, i) : String(i)
|
|
573
|
+
if (newKeys.has(key) && !warnedKeys.has(key)) {
|
|
574
|
+
warnedKeys.add(key)
|
|
575
|
+
console.warn(
|
|
576
|
+
`[BarefootJS] mapArrayAnchored: duplicate key "${key}" — items with this key collapse to a ` +
|
|
577
|
+
`single DOM scope, so only the last one renders. Use a per-item identifier (e.g. \`key={item.id}\`).`,
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
newKeys.add(key)
|
|
581
|
+
|
|
582
|
+
const existing = scopes.get(key)
|
|
583
|
+
if (existing) {
|
|
584
|
+
existing.setItem(item)
|
|
585
|
+
desiredOrder.push(existing)
|
|
586
|
+
} else {
|
|
587
|
+
const scope = createAnchorScope(item, i, key, renderItem)
|
|
588
|
+
scopes.set(key, scope)
|
|
589
|
+
desiredOrder.push(scope)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Remove items no longer present.
|
|
594
|
+
for (const [key, scope] of scopes) {
|
|
595
|
+
if (!newKeys.has(key)) {
|
|
596
|
+
scope.dispose()
|
|
597
|
+
removeAnchorScope(scope, end)
|
|
598
|
+
scopes.delete(key)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Reconcile DOM order. Comparing anchors (not elements) is correct even
|
|
603
|
+
// for empty items, which contribute only their anchor to the range.
|
|
604
|
+
let inOrder = true
|
|
605
|
+
const domAnchors = findItemAnchors(start, end)
|
|
606
|
+
if (domAnchors.length !== desiredOrder.length) {
|
|
607
|
+
inOrder = false
|
|
608
|
+
} else {
|
|
609
|
+
for (let i = 0; i < desiredOrder.length; i++) {
|
|
610
|
+
if (domAnchors[i] !== desiredOrder[i].anchor) { inOrder = false; break }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (!inOrder) {
|
|
614
|
+
for (const scope of desiredOrder) {
|
|
615
|
+
placeAnchorScope(scope, container, end, end)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
}
|
package/src/runtime/scope.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Maps an element to its comment node and the sibling range boundary.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
|
|
8
|
+
import { BF_SCOPE_COMMENT_PREFIX, BF_LOOP_ITEM, BF_LOOP_END } from '@barefootjs/shared'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Information about a comment-based scope.
|
|
@@ -31,14 +31,27 @@ export function getPortalScopeId(element: Element): string | null {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Find the end boundary for a comment-based scope.
|
|
34
|
-
*
|
|
34
|
+
*
|
|
35
|
+
* The boundary depends on the anchor's kind:
|
|
36
|
+
* - `bf-scope:` anchor (fragment-root component): boundary is the next
|
|
37
|
+
* `bf-scope:` comment or the end of the parent's children (unchanged).
|
|
38
|
+
* - `bf-loop-i:<key>` anchor (loop item, #1665): boundary is the next
|
|
39
|
+
* loop-item anchor (`bf-loop-i:*`) or the loop end marker (`bf-/loop:*`),
|
|
40
|
+
* so one item's range never bleeds into the next item or past the loop.
|
|
35
41
|
*/
|
|
36
42
|
export function getCommentScopeBoundary(commentNode: Comment): Node | null {
|
|
43
|
+
const isLoopItem = commentNode.nodeValue?.startsWith(`${BF_LOOP_ITEM}:`) ?? false
|
|
37
44
|
let node: Node | null = commentNode.nextSibling
|
|
38
45
|
while (node) {
|
|
39
|
-
if (node.nodeType === Node.COMMENT_NODE
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
47
|
+
const value = (node as Comment).nodeValue ?? ''
|
|
48
|
+
if (isLoopItem) {
|
|
49
|
+
if (value.startsWith(`${BF_LOOP_ITEM}:`) || value.startsWith(`${BF_LOOP_END}:`)) {
|
|
50
|
+
return node
|
|
51
|
+
}
|
|
52
|
+
} else if (value.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
|
|
53
|
+
return node
|
|
54
|
+
}
|
|
42
55
|
}
|
|
43
56
|
node = node.nextSibling
|
|
44
57
|
}
|