@barefootjs/client 0.33.1 → 0.33.3
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/csr-adapter.js +21 -14
- package/dist/runtime/component.d.ts +52 -1
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +154 -54
- package/dist/runtime/map-array-lazy.d.ts +6 -1
- package/dist/runtime/map-array-lazy.d.ts.map +1 -1
- package/dist/runtime/map-array.d.ts +11 -1
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/qsa-item.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/standalone.js +154 -54
- package/dist/runtime/types.d.ts +19 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/runtime/component.ts +347 -30
- package/src/runtime/hydrate.ts +11 -2
- package/src/runtime/index.ts +1 -0
- package/src/runtime/map-array-lazy.ts +15 -4
- package/src/runtime/map-array.ts +186 -29
- package/src/runtime/qsa-item.ts +3 -1
- package/src/runtime/registry.ts +4 -1
- package/src/runtime/types.ts +19 -1
package/src/runtime/map-array.ts
CHANGED
|
@@ -26,11 +26,38 @@ import {
|
|
|
26
26
|
BF_LOOP_START,
|
|
27
27
|
BF_LOOP_END,
|
|
28
28
|
BF_LOOP_ITEM,
|
|
29
|
+
BF_SCOPE_COMMENT_PREFIX,
|
|
30
|
+
BF_SCOPE_COMMENT_END_PREFIX,
|
|
29
31
|
loopStartMarker,
|
|
30
32
|
loopEndMarker,
|
|
31
33
|
loopItemMarker,
|
|
32
34
|
} from '@barefootjs/shared'
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A fragment-rooted component's own `<!--bf-scope:ID-->` /
|
|
38
|
+
* `<!--bf-/scope:ID-->` boundary pair (`wrapWithScopeComment`,
|
|
39
|
+
* hono-adapter.ts), captured when that component is used as a keyed loop
|
|
40
|
+
* row's `primaryEl` (#2733). Distinct from `ItemScope.startMarker` (the
|
|
41
|
+
* `<!--bf-loop-i-->` marker for a multi-root loop BODY): that marks a
|
|
42
|
+
* loop-body concept this module owns; this marks a component's own scope
|
|
43
|
+
* identity that the row's SSR/CSR emission owns and `commentScopeRegistry`
|
|
44
|
+
* (scope.ts) keys off. `insertScope`/`removeScope` carry it as part of the
|
|
45
|
+
* row's atomic unit so a reorder/removal doesn't orphan it.
|
|
46
|
+
*/
|
|
47
|
+
type ScopeCommentPair = { start: Comment; end: Comment }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The scope id inside a `<!--bf-scope:ID|h=…|m=…|props-->` comment — the
|
|
51
|
+
* `|`-free head, matching `getCommentScopeBoundary` (scope.ts) and
|
|
52
|
+
* `parseCommentScopeId` (query.ts). Used to pair a start comment with its
|
|
53
|
+
* OWN end marker by id, never with a sibling scope's.
|
|
54
|
+
*/
|
|
55
|
+
function scopeIdOf(start: Comment): string {
|
|
56
|
+
const rest = (start.nodeValue ?? '').slice(BF_SCOPE_COMMENT_PREFIX.length)
|
|
57
|
+
const pipe = rest.indexOf('|')
|
|
58
|
+
return pipe >= 0 ? rest.slice(0, pipe) : rest
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
type ItemScope<T> = {
|
|
35
62
|
/**
|
|
36
63
|
* `<!--bf-loop-i-->` Comment that anchors a multi-root item. `null` for
|
|
@@ -49,6 +76,8 @@ type ItemScope<T> = {
|
|
|
49
76
|
* two or more peers). Empty for single-root items.
|
|
50
77
|
*/
|
|
51
78
|
extras: HTMLElement[]
|
|
79
|
+
/** See `ScopeCommentPair`. `null` when `primaryEl` isn't a fragment-root scope. */
|
|
80
|
+
scopeComments: ScopeCommentPair | null
|
|
52
81
|
dispose: () => void
|
|
53
82
|
setItem: (v: T) => void
|
|
54
83
|
}
|
|
@@ -111,46 +140,103 @@ export function findLoopMarkers(
|
|
|
111
140
|
* are present (single-root loops, the common case), each Element forms
|
|
112
141
|
* its own range with `startMarker: null` and `extras: []` — preserving
|
|
113
142
|
* legacy behavior verbatim.
|
|
143
|
+
*
|
|
144
|
+
* Also recognizes a fragment-rooted component's own `<!--bf-scope:ID-->` /
|
|
145
|
+
* `<!--bf-/scope:ID-->` pair immediately bracketing an item's `primaryEl`
|
|
146
|
+
* (#2733) and captures it as `scopeComments`, so the row's caller can pass
|
|
147
|
+
* it through to `createItemScope` and keep it moving with the row.
|
|
148
|
+
*
|
|
149
|
+
* A `|h=` child segment does NOT disqualify a comment here, though
|
|
150
|
+
* `hydrate.ts::hydrateCommentScope` skips exactly those. The two ask
|
|
151
|
+
* different questions. That walker runs at the top level, where a `|h=`
|
|
152
|
+
* comment means "some parent's `initChild` owns this scope, not me." This
|
|
153
|
+
* one runs BETWEEN a loop's own markers, where every row IS a child: real
|
|
154
|
+
* SSR emits each row as `<!--bf-scope:TodoRow_x|h=<host>|m=<slot>|<props>-->`
|
|
155
|
+
* (measured), so requiring a root-shaped comment matched nothing a server
|
|
156
|
+
* ever produces and left the pair behind on every reorder — the exact
|
|
157
|
+
* orphaning this field exists to prevent.
|
|
158
|
+
*
|
|
159
|
+
* The end comment is matched by scope ID rather than by "the next
|
|
160
|
+
* `bf-/scope:` seen", so a sibling root that is itself a fragment-rooted
|
|
161
|
+
* child cannot close this row's pair early.
|
|
114
162
|
*/
|
|
115
163
|
function findItemRanges(start: Comment, end: Comment): Array<{
|
|
116
164
|
startMarker: Comment | null
|
|
117
165
|
primaryEl: HTMLElement
|
|
118
166
|
extras: HTMLElement[]
|
|
167
|
+
scopeComments: ScopeCommentPair | null
|
|
119
168
|
}> {
|
|
169
|
+
type PendingPair = { start: Comment; end: Comment | null }
|
|
120
170
|
const ranges: Array<{
|
|
121
171
|
startMarker: Comment | null
|
|
122
172
|
primaryEl: HTMLElement | null
|
|
123
173
|
extras: HTMLElement[]
|
|
174
|
+
scopeComments: PendingPair | null
|
|
124
175
|
}> = []
|
|
125
|
-
let current:
|
|
176
|
+
let current: (typeof ranges)[number] | null = null
|
|
126
177
|
let sawItemMarker = false
|
|
178
|
+
// A fragment-root row's own start comment, seen but not yet matched to
|
|
179
|
+
// the element it brackets — consumed the instant the next Element is seen.
|
|
180
|
+
let pendingScopeStart: Comment | null = null
|
|
181
|
+
// The pair still awaiting its end comment, so a later `bf-/scope:` knows
|
|
182
|
+
// which range to close — there is at most one open at a time since a row's
|
|
183
|
+
// own scope comment never nests another row's.
|
|
184
|
+
let openScopeComments: PendingPair | null = null
|
|
127
185
|
let node: Node | null = start.nextSibling
|
|
128
186
|
while (node && node !== end) {
|
|
129
|
-
if (node.nodeType === Node.COMMENT_NODE
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
187
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
188
|
+
const value = (node as Comment).nodeValue ?? ''
|
|
189
|
+
if (value === BF_LOOP_ITEM) {
|
|
190
|
+
sawItemMarker = true
|
|
191
|
+
current = { startMarker: node as Comment, primaryEl: null, extras: [], scopeComments: null }
|
|
192
|
+
ranges.push(current)
|
|
193
|
+
} else if (value.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
|
|
194
|
+
pendingScopeStart = node as Comment
|
|
195
|
+
} else if (
|
|
196
|
+
openScopeComments &&
|
|
197
|
+
value === BF_SCOPE_COMMENT_END_PREFIX + scopeIdOf(openScopeComments.start)
|
|
198
|
+
) {
|
|
199
|
+
openScopeComments.end = node as Comment
|
|
200
|
+
openScopeComments = null
|
|
201
|
+
}
|
|
133
202
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
134
203
|
const el = node as HTMLElement
|
|
204
|
+
let scopeComments: PendingPair | null = null
|
|
205
|
+
if (pendingScopeStart) {
|
|
206
|
+
scopeComments = { start: pendingScopeStart, end: null }
|
|
207
|
+
openScopeComments = scopeComments
|
|
208
|
+
pendingScopeStart = null
|
|
209
|
+
}
|
|
135
210
|
if (sawItemMarker) {
|
|
136
|
-
if (!current!.primaryEl)
|
|
137
|
-
|
|
211
|
+
if (!current!.primaryEl) {
|
|
212
|
+
current!.primaryEl = el
|
|
213
|
+
current!.scopeComments = scopeComments
|
|
214
|
+
} else current!.extras.push(el)
|
|
138
215
|
} else {
|
|
139
|
-
ranges.push({ startMarker: null, primaryEl: el, extras: [] })
|
|
216
|
+
ranges.push({ startMarker: null, primaryEl: el, extras: [], scopeComments })
|
|
140
217
|
}
|
|
141
218
|
}
|
|
142
219
|
node = node.nextSibling
|
|
143
220
|
}
|
|
144
|
-
return ranges
|
|
145
|
-
(
|
|
146
|
-
r
|
|
147
|
-
|
|
221
|
+
return ranges
|
|
222
|
+
.filter(
|
|
223
|
+
(r): r is { startMarker: Comment | null; primaryEl: HTMLElement; extras: HTMLElement[]; scopeComments: PendingPair | null } =>
|
|
224
|
+
r.primaryEl !== null,
|
|
225
|
+
)
|
|
226
|
+
.map(r => ({
|
|
227
|
+
...r,
|
|
228
|
+
// Drop a scope-comment pair whose end was never found (malformed/
|
|
229
|
+
// truncated DOM) rather than carry a half-formed pair forward — the
|
|
230
|
+
// row still hydrates correctly, it just won't track the boundary.
|
|
231
|
+
scopeComments: r.scopeComments?.end ? (r.scopeComments as ScopeCommentPair) : null,
|
|
232
|
+
}))
|
|
148
233
|
}
|
|
149
234
|
|
|
150
235
|
/**
|
|
151
236
|
* Insert a scope's nodes into `target` in their canonical order
|
|
152
|
-
* (startMarker → primaryEl →
|
|
153
|
-
* node already at the target
|
|
237
|
+
* (startMarker → scopeComments.start → primaryEl → scopeComments.end →
|
|
238
|
+
* extras). Idempotent — `insertBefore` on a node already at the target
|
|
239
|
+
* position is a no-op.
|
|
154
240
|
*
|
|
155
241
|
* `target` is typed as `Node` (not `HTMLElement`) so callers can pass a
|
|
156
242
|
* `DocumentFragment` to batch several scopes into one subsequent
|
|
@@ -159,7 +245,9 @@ function findItemRanges(start: Comment, end: Comment): Array<{
|
|
|
159
245
|
*/
|
|
160
246
|
function insertScope<T>(scope: ItemScope<T>, target: Node, anchor: Node | null): void {
|
|
161
247
|
if (scope.startMarker) target.insertBefore(scope.startMarker, anchor)
|
|
248
|
+
if (scope.scopeComments) target.insertBefore(scope.scopeComments.start, anchor)
|
|
162
249
|
target.insertBefore(scope.primaryEl, anchor)
|
|
250
|
+
if (scope.scopeComments) target.insertBefore(scope.scopeComments.end, anchor)
|
|
163
251
|
for (const ex of scope.extras) target.insertBefore(ex, anchor)
|
|
164
252
|
}
|
|
165
253
|
|
|
@@ -211,7 +299,9 @@ export function longestIncreasingSubsequenceIndices(arr: number[]): number[] {
|
|
|
211
299
|
/** Detach all of a scope's nodes from the DOM. */
|
|
212
300
|
function removeScope<T>(scope: ItemScope<T>): void {
|
|
213
301
|
if (scope.startMarker?.parentNode) scope.startMarker.remove()
|
|
302
|
+
if (scope.scopeComments?.start.parentNode) scope.scopeComments.start.remove()
|
|
214
303
|
if (scope.primaryEl.parentNode) scope.primaryEl.remove()
|
|
304
|
+
if (scope.scopeComments?.end.parentNode) scope.scopeComments.end.remove()
|
|
215
305
|
for (const ex of scope.extras) {
|
|
216
306
|
if (ex.parentNode) ex.remove()
|
|
217
307
|
}
|
|
@@ -226,6 +316,13 @@ function removeScope<T>(scope: ItemScope<T>): void {
|
|
|
226
316
|
* sibling roots on the returned element via a `__bfExtras` property that
|
|
227
317
|
* we read-and-delete here. On hydration the caller passes `existingExtras`
|
|
228
318
|
* + `existingStart` collected from the SSR partition.
|
|
319
|
+
*
|
|
320
|
+
* Fragment-root row handling (#2733): on CSR, `createComponent`
|
|
321
|
+
* (component.ts's `rowMount` branch) stashes the row's own
|
|
322
|
+
* `<!--bf-scope:ID-->` boundary pair on the returned element via a
|
|
323
|
+
* `__bfScopeComments` property, read-and-deleted here exactly like
|
|
324
|
+
* `__bfExtras`. On hydration the caller passes `existingScopeComments`
|
|
325
|
+
* collected from the SSR partition (`findItemRanges`).
|
|
229
326
|
*/
|
|
230
327
|
function createItemScope<T>(
|
|
231
328
|
item: T,
|
|
@@ -234,6 +331,7 @@ function createItemScope<T>(
|
|
|
234
331
|
existingPrimary?: HTMLElement,
|
|
235
332
|
existingExtras?: HTMLElement[],
|
|
236
333
|
existingStart?: Comment | null,
|
|
334
|
+
existingScopeComments?: ScopeCommentPair | null,
|
|
237
335
|
rowMount?: RowMountPoint | null,
|
|
238
336
|
): ItemScope<T> {
|
|
239
337
|
let primaryEl!: HTMLElement
|
|
@@ -241,6 +339,7 @@ function createItemScope<T>(
|
|
|
241
339
|
let setItem!: (v: T) => void
|
|
242
340
|
let extras: HTMLElement[] = []
|
|
243
341
|
let startMarker: Comment | null = null
|
|
342
|
+
let scopeComments: ScopeCommentPair | null = null
|
|
244
343
|
|
|
245
344
|
createRoot((d) => {
|
|
246
345
|
dispose = d
|
|
@@ -274,6 +373,7 @@ function createItemScope<T>(
|
|
|
274
373
|
if (existingPrimary) {
|
|
275
374
|
extras = existingExtras ?? []
|
|
276
375
|
startMarker = existingStart ?? null
|
|
376
|
+
scopeComments = existingScopeComments ?? null
|
|
277
377
|
} else {
|
|
278
378
|
const stashed = (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
|
|
279
379
|
if (stashed && stashed.length > 0) {
|
|
@@ -281,6 +381,11 @@ function createItemScope<T>(
|
|
|
281
381
|
startMarker = document.createComment(BF_LOOP_ITEM)
|
|
282
382
|
}
|
|
283
383
|
delete (primaryEl as unknown as { __bfExtras?: HTMLElement[] }).__bfExtras
|
|
384
|
+
|
|
385
|
+
const stashedScopeComments = (primaryEl as unknown as { __bfScopeComments?: ScopeCommentPair })
|
|
386
|
+
.__bfScopeComments
|
|
387
|
+
if (stashedScopeComments) scopeComments = stashedScopeComments
|
|
388
|
+
delete (primaryEl as unknown as { __bfScopeComments?: ScopeCommentPair }).__bfScopeComments
|
|
284
389
|
}
|
|
285
390
|
return undefined
|
|
286
391
|
})
|
|
@@ -294,7 +399,7 @@ function createItemScope<T>(
|
|
|
294
399
|
primaryEl.remove()
|
|
295
400
|
}
|
|
296
401
|
|
|
297
|
-
return { startMarker, primaryEl, extras, dispose, setItem }
|
|
402
|
+
return { startMarker, primaryEl, extras, scopeComments, dispose, setItem }
|
|
298
403
|
}
|
|
299
404
|
|
|
300
405
|
/**
|
|
@@ -307,6 +412,16 @@ function createItemScope<T>(
|
|
|
307
412
|
* Receives item as signal accessor: item() returns current value.
|
|
308
413
|
* When `existing` is passed, initializes the SSR-rendered element and returns it.
|
|
309
414
|
* When `existing` is undefined, creates a new element and returns it.
|
|
415
|
+
* @param keyAttrName - The row-key attribute NAME this loop resolved at
|
|
416
|
+
* compile time (`IRElement.keyAttr`/`keyAttrName(loop.depth)`,
|
|
417
|
+
* jsx-to-ir.ts) — `'data-key'` at the outermost loop,
|
|
418
|
+
* `'data-key-N'` N levels deep. Defaults to `BF_KEY`
|
|
419
|
+
* (plain `'data-key'`), correct for every depth-0 call
|
|
420
|
+
* site; only a nested loop's compiled call passes a
|
|
421
|
+
* depth-suffixed name (#2753 Shape B — see the "Why not
|
|
422
|
+
* stamp unconditionally" note below for why this
|
|
423
|
+
* replaced a hardcoded constant instead of just fixing
|
|
424
|
+
* the value).
|
|
310
425
|
*/
|
|
311
426
|
export function mapArray<T>(
|
|
312
427
|
accessor: () => T[],
|
|
@@ -315,6 +430,7 @@ export function mapArray<T>(
|
|
|
315
430
|
renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
|
|
316
431
|
markerId?: string,
|
|
317
432
|
bfId?: string,
|
|
433
|
+
keyAttrName: string = BF_KEY,
|
|
318
434
|
): void {
|
|
319
435
|
if (!container) return
|
|
320
436
|
|
|
@@ -352,21 +468,49 @@ export function mapArray<T>(
|
|
|
352
468
|
const existingRanges = startMarker
|
|
353
469
|
? findItemRanges(startMarker, endMarker!)
|
|
354
470
|
: Array.from(container.children).map(
|
|
355
|
-
(el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
|
|
471
|
+
(el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[], scopeComments: null as ScopeCommentPair | null }),
|
|
356
472
|
)
|
|
357
473
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
474
|
+
// #2753 — why not read the DOM for a hydration signal: this branch
|
|
475
|
+
// runs at most ONCE per `mapArray` call (guarded by the closure-local
|
|
476
|
+
// `hydrated` flag, set true on the line above, before anything else
|
|
477
|
+
// can run), and `scopes` is a closure-local Map that is ALWAYS empty
|
|
478
|
+
// the first time this line runs — nothing sets it earlier. So "has
|
|
479
|
+
// this container already been adopted" is already fully answered by
|
|
480
|
+
// `existingRanges.length > 0` (there is DOM here from SSR/a prior
|
|
481
|
+
// render) with no attribute read at all. The attribute the old check
|
|
482
|
+
// read (`hasAttribute('data-key')`) was never a sound signal anyway:
|
|
483
|
+
// an unkeyed loop's rows legitimately carry no key attribute at all
|
|
484
|
+
// (Shape A), and a nested loop's rows carry a depth-suffixed name
|
|
485
|
+
// (`data-key-N`, Shape B) — so the plain-name probe either
|
|
486
|
+
// misdiagnosed a correctly-unkeyed row as "not yet hydrated", or
|
|
487
|
+
// missed a correctly-keyed nested row entirely. `scopes.size === 0`
|
|
488
|
+
// is not a second, independent signal here — it is ALWAYS true at
|
|
489
|
+
// this line, so the two-part `||` reduced to the first probe being
|
|
490
|
+
// moot: this is a behavior-preserving simplification, not a new rule.
|
|
361
491
|
const needsHydration = existingRanges.length > 0
|
|
362
|
-
&& (!existingRanges[0]?.primaryEl.hasAttribute('data-key') || scopes.size === 0)
|
|
363
492
|
if (needsHydration) {
|
|
364
493
|
// Hydrate in place: tag keys, create per-item scopes with renderItem(existing)
|
|
365
494
|
for (let i = 0; i < existingRanges.length && i < items.length; i++) {
|
|
366
495
|
const range = existingRanges[i]
|
|
367
496
|
const item = items[i]
|
|
368
497
|
const key = getKey ? getKey(item, i) : String(i)
|
|
369
|
-
|
|
498
|
+
// Only a KEYED loop gets a key attribute at all (Shape A) — SSR
|
|
499
|
+
// (or the static template this row cloned from) already carries
|
|
500
|
+
// the correct, depth-aware name (Shape B) when it applies, so this
|
|
501
|
+
// is a defensive backfill for a row that arrived without one, not
|
|
502
|
+
// the primary writer. A FALSY-value check (`getAttribute`, not
|
|
503
|
+
// `hasAttribute`) — not just presence — because a compiled
|
|
504
|
+
// template can bake the attribute PRESENT but empty for a shape
|
|
505
|
+
// whose key expression isn't ready at clone time (measured on the
|
|
506
|
+
// csr-mount leg of a keyed top-level loop); `hasAttribute` would
|
|
507
|
+
// wrongly treat that empty placeholder as "already correct" and
|
|
508
|
+
// skip the backfill this line exists to make. Reads the SAME name
|
|
509
|
+
// being written (not `dataset.key`, which only ever reads the
|
|
510
|
+
// plain, undepth-suffixed name).
|
|
511
|
+
if (getKey && !range.primaryEl.getAttribute(keyAttrName)) {
|
|
512
|
+
range.primaryEl.setAttribute(keyAttrName, key)
|
|
513
|
+
}
|
|
370
514
|
|
|
371
515
|
const scope = createItemScope(
|
|
372
516
|
item,
|
|
@@ -375,6 +519,7 @@ export function mapArray<T>(
|
|
|
375
519
|
range.primaryEl,
|
|
376
520
|
range.extras,
|
|
377
521
|
range.startMarker,
|
|
522
|
+
range.scopeComments,
|
|
378
523
|
)
|
|
379
524
|
scopes.set(key, scope)
|
|
380
525
|
hydratedScopes.add(range.primaryEl)
|
|
@@ -386,8 +531,8 @@ export function mapArray<T>(
|
|
|
386
531
|
const key = getKey ? getKey(item, i) : String(i)
|
|
387
532
|
// Final position is known here (append before the loop's trailing
|
|
388
533
|
// anchor), so the row can be mounted at it before its init runs.
|
|
389
|
-
const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
|
|
390
|
-
if (!scope.primaryEl.
|
|
534
|
+
const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, undefined, { container, anchor })
|
|
535
|
+
if (getKey && !scope.primaryEl.getAttribute(keyAttrName)) scope.primaryEl.setAttribute(keyAttrName, key)
|
|
391
536
|
scopes.set(key, scope)
|
|
392
537
|
insertScope(scope, container, anchor)
|
|
393
538
|
}
|
|
@@ -396,7 +541,9 @@ export function mapArray<T>(
|
|
|
396
541
|
for (let i = items.length; i < existingRanges.length; i++) {
|
|
397
542
|
const range = existingRanges[i]
|
|
398
543
|
if (range.startMarker?.parentNode) range.startMarker.remove()
|
|
544
|
+
if (range.scopeComments?.start.parentNode) range.scopeComments.start.remove()
|
|
399
545
|
if (range.primaryEl.parentNode) range.primaryEl.remove()
|
|
546
|
+
if (range.scopeComments?.end.parentNode) range.scopeComments.end.remove()
|
|
400
547
|
for (const ex of range.extras) {
|
|
401
548
|
if (ex.parentNode) ex.remove()
|
|
402
549
|
}
|
|
@@ -411,15 +558,19 @@ export function mapArray<T>(
|
|
|
411
558
|
const loopRanges = startMarker
|
|
412
559
|
? findItemRanges(startMarker, endMarker!)
|
|
413
560
|
: Array.from(container.children).map(
|
|
414
|
-
(el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[] }),
|
|
561
|
+
(el) => ({ startMarker: null, primaryEl: el as HTMLElement, extras: [] as HTMLElement[], scopeComments: null as ScopeCommentPair | null }),
|
|
415
562
|
)
|
|
416
563
|
for (const range of loopRanges) {
|
|
417
|
-
|
|
564
|
+
// `getAttribute(keyAttrName)`, not `dataset.key` — `dataset` only
|
|
565
|
+
// ever reads the plain, undepth-suffixed `data-key`, which misses a
|
|
566
|
+
// nested loop's `data-key-N` rows entirely (#2753 Shape B).
|
|
567
|
+
const existingKey = range.primaryEl.getAttribute(keyAttrName)
|
|
418
568
|
if (existingKey && !scopes.has(existingKey)) {
|
|
419
569
|
scopes.set(existingKey, {
|
|
420
570
|
startMarker: range.startMarker,
|
|
421
571
|
primaryEl: range.primaryEl,
|
|
422
572
|
extras: range.extras,
|
|
573
|
+
scopeComments: range.scopeComments,
|
|
423
574
|
dispose: () => {},
|
|
424
575
|
setItem: () => {},
|
|
425
576
|
})
|
|
@@ -450,6 +601,7 @@ export function mapArray<T>(
|
|
|
450
601
|
let expectedNodeCount = 0
|
|
451
602
|
for (const scope of scopes.values()) {
|
|
452
603
|
expectedNodeCount += 1 + scope.extras.length + (scope.startMarker ? 1 : 0)
|
|
604
|
+
+ (scope.scopeComments ? 2 : 0)
|
|
453
605
|
}
|
|
454
606
|
let actualNodeCount = 0
|
|
455
607
|
for (let node = container.firstChild; node; node = node.nextSibling) actualNodeCount++
|
|
@@ -501,8 +653,8 @@ export function mapArray<T>(
|
|
|
501
653
|
// the loop range before its init runs; the LIS reorder below moves it
|
|
502
654
|
// to its final position (it participates in the walk like any other
|
|
503
655
|
// attached scope, so the resulting order is unchanged).
|
|
504
|
-
const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
|
|
505
|
-
if (!scope.primaryEl.
|
|
656
|
+
const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, undefined, { container, anchor })
|
|
657
|
+
if (getKey && !scope.primaryEl.getAttribute(keyAttrName)) scope.primaryEl.setAttribute(keyAttrName, key)
|
|
506
658
|
scopes.set(key, scope)
|
|
507
659
|
desiredOrder.push(scope)
|
|
508
660
|
}
|
|
@@ -586,9 +738,14 @@ export function mapArray<T>(
|
|
|
586
738
|
while (j < desiredOrder.length && !stationary[j]) j++
|
|
587
739
|
// Insert this run immediately before the next stationary scope (which
|
|
588
740
|
// is already exactly where it needs to be), or before the loop's
|
|
589
|
-
// trailing anchor when the run reaches the end of the list.
|
|
741
|
+
// trailing anchor when the run reaches the end of the list. Preferring
|
|
742
|
+
// `scopeComments.start` over `primaryEl` here matters when that next
|
|
743
|
+
// scope is a fragment-root row (#2733): inserting directly before its
|
|
744
|
+
// `primaryEl` would land the new run BETWEEN its still-attached
|
|
745
|
+
// `<!--bf-scope:-->` start comment and its element, splitting a
|
|
746
|
+
// stationary scope's own boundary pair.
|
|
590
747
|
const before = j < desiredOrder.length
|
|
591
|
-
? (desiredOrder[j].startMarker ?? desiredOrder[j].primaryEl)
|
|
748
|
+
? (desiredOrder[j].startMarker ?? desiredOrder[j].scopeComments?.start ?? desiredOrder[j].primaryEl)
|
|
592
749
|
: anchor
|
|
593
750
|
if (j - i === 1) {
|
|
594
751
|
insertScope(desiredOrder[i], container, before)
|
package/src/runtime/qsa-item.ts
CHANGED
|
@@ -137,7 +137,9 @@ export function upsertChildItem(
|
|
|
137
137
|
const ph = qsaItem(primaryEl, `[data-bf-ph="${phId}"]`) as HTMLElement | null
|
|
138
138
|
if (ph) {
|
|
139
139
|
const slot = slotId ? buildSlotInfo(primaryEl, slotId, anchorScope) : undefined
|
|
140
|
-
// Connect before init — see the same call in `upsertChild`.
|
|
140
|
+
// Connect before init — see the same call in `upsertChild`. `mountAt`
|
|
141
|
+
// (`ph`) is always given here, so this lands on `createComponent`'s
|
|
142
|
+
// non-null-`mountAt` overload and is typed `HTMLElement`.
|
|
141
143
|
return createComponent(name, props, key, slot, ph)
|
|
142
144
|
}
|
|
143
145
|
return null
|
package/src/runtime/registry.ts
CHANGED
|
@@ -161,7 +161,10 @@ export function upsertChild(
|
|
|
161
161
|
// Hand the placeholder to `createComponent` so the new element is
|
|
162
162
|
// connected before its init runs — `useContext` resolves by DOM
|
|
163
163
|
// position, and a detached init silently fell back to the global
|
|
164
|
-
// context store.
|
|
164
|
+
// context store. `mountAt` (`ph`) is always given here, so this lands
|
|
165
|
+
// on `createComponent`'s non-null-`mountAt` overload and is typed
|
|
166
|
+
// `HTMLElement` — the bare-fragment-root `DocumentFragment` shape is
|
|
167
|
+
// unreachable from a call that names its mount point.
|
|
165
168
|
return createComponent(name, props, key, slot, ph)
|
|
166
169
|
}
|
|
167
170
|
return null
|
package/src/runtime/types.ts
CHANGED
|
@@ -22,6 +22,24 @@ export interface ComponentDef {
|
|
|
22
22
|
init: InitFn
|
|
23
23
|
/** Template function for client-side component creation */
|
|
24
24
|
template?: (props: Record<string, unknown>) => string
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* When true, this component's scope has no `bf-s`-carrying element of
|
|
27
|
+
* its own — a proxy element stands in for it. Set for TWO distinct
|
|
28
|
+
* shapes (see `fragmentRoot` below, which tells them apart):
|
|
29
|
+
* - a genuine fragment root (`<>...</>`), where the proxy is the
|
|
30
|
+
* fragment's own rendered content;
|
|
31
|
+
* - a root that is itself a single child component call, where the
|
|
32
|
+
* proxy is that child's own already-scoped element (#2649).
|
|
33
|
+
*/
|
|
26
34
|
comment?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* True only for the genuine-fragment-root shape of `comment` above
|
|
37
|
+
* (`ir.root.type === 'fragment'`) — never for the root-is-a-child-call
|
|
38
|
+
* shape. `materializeComponent` (component.ts) uses this to decide
|
|
39
|
+
* whether a CSR mount must generate its OWN scope id (fragment root: yes,
|
|
40
|
+
* so nested `renderChild()` calls get parent-prefixed naming matching
|
|
41
|
+
* SSR/hydrate) or leave the scope id null to avoid overwriting the
|
|
42
|
+
* child's own (#2649's shape, #2722).
|
|
43
|
+
*/
|
|
44
|
+
fragmentRoot?: boolean
|
|
27
45
|
}
|