@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/component.ts
CHANGED
|
@@ -11,7 +11,17 @@ import { getRegisteredDef } from './hydrate.ts'
|
|
|
11
11
|
import { hydratedScopes } from './hydration-state.ts'
|
|
12
12
|
import { untrack } from '@barefootjs/client/reactive'
|
|
13
13
|
import { setCurrentScope } from './context.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { commentScopeRegistry } from './scope.ts'
|
|
15
|
+
import {
|
|
16
|
+
BF_SCOPE,
|
|
17
|
+
BF_KEY,
|
|
18
|
+
BF_HOST,
|
|
19
|
+
BF_AT,
|
|
20
|
+
BF_PARENT_SCOPE_PLACEHOLDER,
|
|
21
|
+
BF_PLACEHOLDER,
|
|
22
|
+
BF_SCOPE_COMMENT_PREFIX,
|
|
23
|
+
BF_SCOPE_COMMENT_END_PREFIX,
|
|
24
|
+
} from '@barefootjs/shared'
|
|
15
25
|
import type { ComponentDef } from './types.ts'
|
|
16
26
|
|
|
17
27
|
// Parent scope ID context for renderChild() inside insert() branch templates.
|
|
@@ -137,14 +147,56 @@ export interface CreateComponentSlotInfo {
|
|
|
137
147
|
mount: string
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The `HTMLElement | DocumentFragment` return covers exactly one shape: a
|
|
152
|
+
* BARE call (no `mountAt`, no ambient row-mount point) for a genuine
|
|
153
|
+
* fragment-root component (#2722). Every other combination — a normal
|
|
154
|
+
* component, or a fragment-root one with `mountAt`/row-mount already
|
|
155
|
+
* telling this function where to connect — still returns the real,
|
|
156
|
+
* single `HTMLElement`, unchanged (that element stays the caller-visible
|
|
157
|
+
* proxy even when the fragment root has further sibling roots of its own
|
|
158
|
+
* — #2735 — those travel alongside it, never in place of it). Only the
|
|
159
|
+
* no-known-destination case has no single element to hand back: the
|
|
160
|
+
* fragment root's own `<!--bf-scope:-->` boundary comments PLUS every
|
|
161
|
+
* top-level node the fragment's template rendered — elements, bare text
|
|
162
|
+
* and `<!--bf:sN-->` slot markers alike (`materializeComponent` step 7b)
|
|
163
|
+
* — must travel to wherever the caller inserts the result, and a
|
|
164
|
+
* `DocumentFragment` is the one `Node` a plain `container.appendChild(...)`
|
|
165
|
+
* / `el.replaceWith(...)` moves as a unit without the caller needing to
|
|
166
|
+
* know why.
|
|
167
|
+
*
|
|
168
|
+
* The first overload states that in the type system rather than only here:
|
|
169
|
+
* a call that passes a non-null `mountAt` is telling this function where to
|
|
170
|
+
* connect, so it can only get the real `HTMLElement` back. Callers on that
|
|
171
|
+
* overload need no cast, which is why `upsertChild` (registry.ts) and
|
|
172
|
+
* `upsertChildItem` (qsa-item.ts) assert nothing — the narrowing is the
|
|
173
|
+
* signature's job, not theirs.
|
|
174
|
+
*/
|
|
175
|
+
export function createComponent(
|
|
176
|
+
nameOrDef: string | ComponentDef,
|
|
177
|
+
props: Record<string, unknown>,
|
|
178
|
+
key: string | number | undefined,
|
|
179
|
+
slot: CreateComponentSlotInfo | undefined,
|
|
180
|
+
mountAt: Element,
|
|
181
|
+
keyAttrName?: string,
|
|
182
|
+
): HTMLElement
|
|
183
|
+
export function createComponent(
|
|
184
|
+
nameOrDef: string | ComponentDef,
|
|
185
|
+
props?: Record<string, unknown>,
|
|
186
|
+
key?: string | number,
|
|
187
|
+
slot?: CreateComponentSlotInfo,
|
|
188
|
+
mountAt?: Element | null,
|
|
189
|
+
keyAttrName?: string,
|
|
190
|
+
): HTMLElement | DocumentFragment
|
|
140
191
|
export function createComponent(
|
|
141
192
|
nameOrDef: string | ComponentDef,
|
|
142
193
|
props: Record<string, unknown> = {},
|
|
143
194
|
key?: string | number,
|
|
144
195
|
slot?: CreateComponentSlotInfo,
|
|
145
196
|
mountAt?: Element | null,
|
|
146
|
-
|
|
147
|
-
|
|
197
|
+
keyAttrName: string = BF_KEY,
|
|
198
|
+
): HTMLElement | DocumentFragment {
|
|
199
|
+
const element = materializeComponent(nameOrDef, props, key, slot, mountAt, keyAttrName)
|
|
148
200
|
// `mountAt` is an unconditional obligation: callers used to run
|
|
149
201
|
// `ph.replaceWith(comp)` themselves on every outcome, so every path that
|
|
150
202
|
// did NOT consume the placeholder still owes the replacement — a missing or
|
|
@@ -152,7 +204,9 @@ export function createComponent(
|
|
|
152
204
|
// which must stay detached so its self-replacement stays recoverable.
|
|
153
205
|
// `parentNode` (not `isConnected`) is the right "still unconsumed" probe: it
|
|
154
206
|
// survives a `mountAt` that was itself detached, which is the normal case
|
|
155
|
-
// during multi-root loop-body setup.
|
|
207
|
+
// during multi-root loop-body setup. A fragment-root's `DocumentFragment`
|
|
208
|
+
// return only ever happens when `mountAt` is absent (see above), so it
|
|
209
|
+
// never reaches this branch.
|
|
156
210
|
if (mountAt && mountAt.parentNode && element !== mountAt) {
|
|
157
211
|
mountAt.replaceWith(element)
|
|
158
212
|
}
|
|
@@ -173,7 +227,8 @@ function materializeComponent(
|
|
|
173
227
|
key?: string | number,
|
|
174
228
|
slot?: CreateComponentSlotInfo,
|
|
175
229
|
mountAt?: Element | null,
|
|
176
|
-
|
|
230
|
+
keyAttrName: string = BF_KEY,
|
|
231
|
+
): HTMLElement | DocumentFragment {
|
|
177
232
|
// A bare callable shim invoked from user code (e.g. an object-literal
|
|
178
233
|
// value `LOGOS[id]()` whose arrow the compiler hoisted into a component)
|
|
179
234
|
// reaches us with no props (#1663). Normalize to an empty object so the
|
|
@@ -184,7 +239,7 @@ function materializeComponent(
|
|
|
184
239
|
const rowMount = mountAt ? null : takeRowMountPoint()
|
|
185
240
|
// ComponentDef mode: use def directly instead of registry lookup
|
|
186
241
|
if (typeof nameOrDef !== 'string') {
|
|
187
|
-
return createComponentFromDef(nameOrDef, props, key, mountAt, rowMount)
|
|
242
|
+
return createComponentFromDef(nameOrDef, props, key, mountAt, rowMount, keyAttrName)
|
|
188
243
|
}
|
|
189
244
|
|
|
190
245
|
const name = nameOrDef
|
|
@@ -193,7 +248,7 @@ function materializeComponent(
|
|
|
193
248
|
const templateFn = getTemplate(name)
|
|
194
249
|
if (!templateFn) {
|
|
195
250
|
console.warn(`[BarefootJS] Template not found for component: ${name}`)
|
|
196
|
-
return createPlaceholder(name, key)
|
|
251
|
+
return createPlaceholder(name, key, keyAttrName)
|
|
197
252
|
}
|
|
198
253
|
|
|
199
254
|
// 2. Check for getter children.
|
|
@@ -230,11 +285,22 @@ function materializeComponent(
|
|
|
230
285
|
|
|
231
286
|
// 4. Pre-generate the component's scope ID.
|
|
232
287
|
//
|
|
233
|
-
// `comment: true` components
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
288
|
+
// `comment: true` components are proxy-scoped — no element of their own
|
|
289
|
+
// carries `bf-s` directly — but that covers TWO different shapes
|
|
290
|
+
// (`ComponentDef.fragmentRoot`'s docstring, types.ts) that need OPPOSITE
|
|
291
|
+
// treatment here:
|
|
292
|
+
// - root-is-a-child-call (#1211/#2649, `fragmentRoot` false): the
|
|
293
|
+
// parsed `firstChild` IS the child's own already-scoped element.
|
|
294
|
+
// Don't overwrite it (scopeId stays null), or `$c(__scope, 's0')`
|
|
295
|
+
// from the wrapper's init resolves to null.
|
|
296
|
+
// - genuine fragment root (`fragmentRoot` true): the parsed `firstChild`
|
|
297
|
+
// carries NO scope of its own (SSR moves it into the wrapping
|
|
298
|
+
// `<!--bf-scope:-->` comment) — generate one just the same, so
|
|
299
|
+
// `_parentScopeId` below still gets threaded into nested
|
|
300
|
+
// `renderChild()` calls and their naming matches SSR/hydrate (#2722:
|
|
301
|
+
// leaving this null made every nested child fall back to a random,
|
|
302
|
+
// un-prefixed scope id — `Select_xyz` instead of the expected
|
|
303
|
+
// `SelectBasicDemo_xyz_s8`).
|
|
238
304
|
//
|
|
239
305
|
// `slot` is only supplied by `upsertChild` / `upsertChildItem` mounting a
|
|
240
306
|
// component nested below a loop row root — the SSR reference (Hono)
|
|
@@ -245,11 +311,12 @@ function materializeComponent(
|
|
|
245
311
|
// random id, matching the reference behaviour.
|
|
246
312
|
const def = getRegisteredDef(name)
|
|
247
313
|
const isCommentWrapper = def?.comment === true
|
|
314
|
+
const isFragmentRoot = def?.fragmentRoot === true
|
|
248
315
|
const derivedScopeId = slot?.parent && slot.mount ? `${slot.parent}_${slot.mount}` : null
|
|
249
316
|
// Same as in `renderChild`: `name` is the registry key, which is
|
|
250
317
|
// file-scoped (`Name__<8hex>`) for a non-exported component. The scope ID
|
|
251
318
|
// must carry the plain name — see `ComponentDef.name` (#2518).
|
|
252
|
-
const scopeId = isCommentWrapper ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
|
|
319
|
+
const scopeId = (isCommentWrapper && !isFragmentRoot) ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
|
|
253
320
|
|
|
254
321
|
// 5. Generate HTML from props.
|
|
255
322
|
//
|
|
@@ -265,11 +332,32 @@ function materializeComponent(
|
|
|
265
332
|
// #2444's `grandchild-composition` case). A comment wrapper keeps
|
|
266
333
|
// `scopeId === null` and falls through to `slot?.parent`, preserving the
|
|
267
334
|
// hoisted-children placeholder resolution (#1320).
|
|
335
|
+
//
|
|
336
|
+
// Third branch (#2757): a root-is-a-child-call wrapper mounted at the TOP
|
|
337
|
+
// LEVEL has neither. `scopeId` is null by design (step 4 — the parsed
|
|
338
|
+
// firstChild is the child's own already-scoped element, so we must not
|
|
339
|
+
// stamp over it) and a top-level `createComponent(name, {})` is passed no
|
|
340
|
+
// `slot`, so pre-#2757 `_parentScopeId` stayed null for the whole template
|
|
341
|
+
// eval and `renderChild` fell back to naming the child after ITSELF
|
|
342
|
+
// (`PairwiseRow_xyz_s2` where SSR and hydration both produce
|
|
343
|
+
// `PairwiseCase_xyz_s2`, and with no `bf-h`/`bf-m` at all). The wrapper
|
|
344
|
+
// still HAS a scope identity in the SSR convention — it just has no
|
|
345
|
+
// element of its own to carry it — so derive one here for threading only.
|
|
346
|
+
// Same split #2722 made for a genuine fragment root, which keeps a
|
|
347
|
+
// non-null `scopeId` purely so this threading works and skips only the
|
|
348
|
+
// ATTRIBUTE write in step 7.
|
|
349
|
+
//
|
|
350
|
+
// Why not derive unconditionally: guarded on `!_parentScopeId` so a
|
|
351
|
+
// wrapper materialized while an OUTER template eval is in flight keeps
|
|
352
|
+
// inheriting that caller's ambient scope rather than being renamed under a
|
|
353
|
+
// fresh random id. Only the genuinely-rootless mount is affected.
|
|
268
354
|
const prevParentScopeId = _parentScopeId
|
|
269
355
|
if (scopeId) {
|
|
270
356
|
_parentScopeId = scopeId
|
|
271
357
|
} else if (slot?.parent) {
|
|
272
358
|
_parentScopeId = slot.parent
|
|
359
|
+
} else if (!_parentScopeId) {
|
|
360
|
+
_parentScopeId = `${def?.name ?? name}_${generateId()}`
|
|
273
361
|
}
|
|
274
362
|
let html: string
|
|
275
363
|
try {
|
|
@@ -278,16 +366,66 @@ function materializeComponent(
|
|
|
278
366
|
_parentScopeId = prevParentScopeId
|
|
279
367
|
}
|
|
280
368
|
|
|
281
|
-
// 6. Create DOM
|
|
282
|
-
|
|
369
|
+
// 6. Create DOM node(s).
|
|
370
|
+
//
|
|
371
|
+
// A genuine fragment root's template concatenates EVERY top-level
|
|
372
|
+
// sibling into one HTML string, so `roots` is the whole ordered list —
|
|
373
|
+
// `parseHTML(...).firstChild` used to be the only node kept, silently
|
|
374
|
+
// dropping the rest (#2735). Everything travels, whatever its node
|
|
375
|
+
// type: a fragment's top level is not only elements. Bare text between
|
|
376
|
+
// two element roots (`<><h1/>text<p/></>`) is a root, and a reactive
|
|
377
|
+
// text slot sitting there renders as a `<!--bf:sN-->` marker. Both were
|
|
378
|
+
// measured being dropped by an element-only walk — the text as a
|
|
379
|
+
// visible SSR/CSR-mount diff, the marker as something worse, since the
|
|
380
|
+
// runtime's own slot lookup then finds nothing to bind.
|
|
381
|
+
//
|
|
382
|
+
// `element` is the PROXY: the one node threaded through init /
|
|
383
|
+
// `commentScopeRegistry` / the return value. It must be an Element —
|
|
384
|
+
// everything downstream calls `setAttribute`/`hasAttribute` on it — so
|
|
385
|
+
// it is the first ELEMENT among the roots, not simply the first node.
|
|
386
|
+
// `<>text<p/></>` puts a Text node first, and taking that as the proxy
|
|
387
|
+
// threw `element.hasAttribute is not a function` at step 7b (measured;
|
|
388
|
+
// pre-dates #2735's fix, which is why the roots list and the proxy are
|
|
389
|
+
// chosen separately rather than the proxy being `roots[0]`).
|
|
390
|
+
//
|
|
391
|
+
// Only `isFragmentRoot` templates can emit more than one top-level node
|
|
392
|
+
// (jsx-to-ir.ts's `transformFragment`), so every other shape keeps
|
|
393
|
+
// exactly the single-node list it always had.
|
|
394
|
+
const parsedFragment = parseHTML(html.trim())
|
|
395
|
+
const roots: Node[] = isFragmentRoot
|
|
396
|
+
? Array.from(parsedFragment.childNodes)
|
|
397
|
+
: parsedFragment.firstChild
|
|
398
|
+
? [parsedFragment.firstChild]
|
|
399
|
+
: []
|
|
400
|
+
const element = (isFragmentRoot
|
|
401
|
+
? roots.find(node => node.nodeType === Node.ELEMENT_NODE)
|
|
402
|
+
: roots[0]) as HTMLElement | undefined
|
|
403
|
+
|
|
404
|
+
// A fragment root with no element at all (`<>just text</>`) has nothing
|
|
405
|
+
// that can carry a scope. Refuse it the same way an empty template is
|
|
406
|
+
// refused rather than crashing on the first `setAttribute` — loud, not
|
|
407
|
+
// silent, per the sound-or-loud rule.
|
|
408
|
+
if (isFragmentRoot && roots.length > 0 && !element) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`[BarefootJS] Fragment-root component ${name} rendered no element root; ` +
|
|
411
|
+
'a scope needs at least one element to attach to. Wrap the content in an element.',
|
|
412
|
+
)
|
|
413
|
+
return createPlaceholder(name, key, keyAttrName)
|
|
414
|
+
}
|
|
283
415
|
|
|
284
416
|
if (!element) {
|
|
285
417
|
console.warn(`[BarefootJS] Template returned empty HTML for component: ${name}`)
|
|
286
|
-
return createPlaceholder(name, key)
|
|
418
|
+
return createPlaceholder(name, key, keyAttrName)
|
|
287
419
|
}
|
|
288
420
|
|
|
289
421
|
// 7. Set scope ID and key attributes.
|
|
290
|
-
|
|
422
|
+
//
|
|
423
|
+
// A genuine fragment root carries its scope id on a WRAPPING comment
|
|
424
|
+
// pair, never as a `bf-s` attribute on the element itself — matching
|
|
425
|
+
// `wrapWithScopeComment` (hono-adapter.ts) and `hydrateCommentScope`
|
|
426
|
+
// (hydrate.ts). `scopeId` is still non-null for this shape (step 4) so
|
|
427
|
+
// `_parentScopeId` threads correctly; only the ATTRIBUTE is skipped here.
|
|
428
|
+
if (scopeId && !isFragmentRoot) {
|
|
291
429
|
element.setAttribute(BF_SCOPE, scopeId)
|
|
292
430
|
}
|
|
293
431
|
if (slot) {
|
|
@@ -295,7 +433,28 @@ function materializeComponent(
|
|
|
295
433
|
element.setAttribute(BF_AT, slot.mount)
|
|
296
434
|
}
|
|
297
435
|
if (key !== undefined) {
|
|
298
|
-
element.setAttribute(
|
|
436
|
+
element.setAttribute(keyAttrName, String(key))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 7a. Fragment-root boundary comments + registry (#2722).
|
|
440
|
+
//
|
|
441
|
+
// `find()`/`$()`/`$c()` (query.ts) resolve a slot or child scope by
|
|
442
|
+
// walking `commentScopeRegistry`'s stored comment and its boundary
|
|
443
|
+
// (`getCommentScopeBoundary`, scope.ts) — that walk needs REAL, sibling-
|
|
444
|
+
// connected comment nodes, not just a registry entry, or `find()`'s
|
|
445
|
+
// comment-scope branch enumerates zero candidates (worse than the
|
|
446
|
+
// fallback `querySelectorAll` path a non-fragment scope gets). So these
|
|
447
|
+
// are built now and threaded through to wherever `element` ends up
|
|
448
|
+
// connected below, exactly mirroring the SSR/hydrate shape:
|
|
449
|
+
// <!--bf-scope:ID-->` + element + `<!--bf-/scope:ID-->`
|
|
450
|
+
const fragmentComments = isFragmentRoot && scopeId
|
|
451
|
+
? {
|
|
452
|
+
start: document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`),
|
|
453
|
+
end: document.createComment(`${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}`),
|
|
454
|
+
}
|
|
455
|
+
: null
|
|
456
|
+
if (fragmentComments) {
|
|
457
|
+
commentScopeRegistry.set(element, { commentNode: fragmentComments.start, scopeId: scopeId! })
|
|
299
458
|
}
|
|
300
459
|
|
|
301
460
|
// 7b. Connect before init.
|
|
@@ -317,14 +476,76 @@ function materializeComponent(
|
|
|
317
476
|
// live DOM with no handle on the result, so this shape keeps the
|
|
318
477
|
// detached behaviour.
|
|
319
478
|
const rootIsDeferredPlaceholder = element.hasAttribute(BF_PLACEHOLDER)
|
|
479
|
+
// A fragment root's boundary comments must land adjacent to `element`
|
|
480
|
+
// at the SAME moment it connects, in each of the three shapes below —
|
|
481
|
+
// there is no later hook to attach them once the caller has taken the
|
|
482
|
+
// return value away (see `createComponent`'s docstring for the fourth,
|
|
483
|
+
// no-known-destination shape, handled after `init` runs).
|
|
484
|
+
let bareFragment: DocumentFragment | null = null
|
|
320
485
|
if (mountAt && !rootIsDeferredPlaceholder) {
|
|
321
|
-
|
|
486
|
+
if (fragmentComments) {
|
|
487
|
+
mountAt.replaceWith(fragmentComments.start, ...roots, fragmentComments.end)
|
|
488
|
+
} else {
|
|
489
|
+
mountAt.replaceWith(element)
|
|
490
|
+
}
|
|
322
491
|
} else if (rowMount && !rootIsDeferredPlaceholder) {
|
|
323
492
|
// Loop row: no placeholder exists, so connect at the position `mapArray`
|
|
324
493
|
// handed down. The reorder step may move the row afterwards; any position
|
|
325
494
|
// inside the container yields the same ancestor chain, which is all
|
|
326
495
|
// `useContext`'s parentElement walk needs.
|
|
327
|
-
|
|
496
|
+
if (fragmentComments) {
|
|
497
|
+
rowMount.container.insertBefore(fragmentComments.start, rowMount.anchor)
|
|
498
|
+
rowMount.container.insertBefore(element, rowMount.anchor)
|
|
499
|
+
rowMount.container.insertBefore(fragmentComments.end, rowMount.anchor)
|
|
500
|
+
// Hand the boundary pair to `mapArray`'s row bookkeeping (map-array.ts's
|
|
501
|
+
// `ItemScope.scopeComments`, #2733) via the same stash-on-the-element
|
|
502
|
+
// convention `__bfExtras` uses for a multi-root loop BODY's extra
|
|
503
|
+
// siblings: `createItemScope` reads and deletes this property right
|
|
504
|
+
// after `renderItem` returns, since there is no other channel back to
|
|
505
|
+
// the caller once `element` is the only thing returned below. Without
|
|
506
|
+
// this, a later reorder/removal of the row moves/removes `element`
|
|
507
|
+
// and leaves the comments behind, orphaned in the container.
|
|
508
|
+
;(element as unknown as { __bfScopeComments?: { start: Comment; end: Comment } }).__bfScopeComments =
|
|
509
|
+
fragmentComments
|
|
510
|
+
// Deliberately NOT inserting the other roots here (a fragment-root
|
|
511
|
+
// component whose OWN render has 2+ top-level nodes, used as a loop
|
|
512
|
+
// row) — connecting them is a separate gap from the boundary-comment
|
|
513
|
+
// tracking #2733 fixed above: even with `ItemScope` now able to carry
|
|
514
|
+
// the row's comments, there is still nowhere on `ItemScope` for a
|
|
515
|
+
// second or third top-level ELEMENT of the row itself (as opposed to
|
|
516
|
+
// `extras`, which is the multi-root loop BODY's own, unrelated,
|
|
517
|
+
// per-item marker convention). Not reachable by any currently tracked
|
|
518
|
+
// fixture (no fragment-root component with 2+ top-level nodes is used
|
|
519
|
+
// as a loop row in the mutation corpus), so declared rather than grown
|
|
520
|
+
// here:
|
|
521
|
+
// https://github.com/piconic-ai/barefootjs/issues/2733
|
|
522
|
+
//
|
|
523
|
+
// Loud, not silent: the whole point of the fix above is that
|
|
524
|
+
// dropping roots without saying so is the failure mode. A gap that
|
|
525
|
+
// stays quiet is indistinguishable from correctness at the call
|
|
526
|
+
// site, so the one shape still dropping them says so.
|
|
527
|
+
if (roots.length > 1) {
|
|
528
|
+
console.warn(
|
|
529
|
+
`[BarefootJS] Fragment-root component ${name} used as a loop row renders ` +
|
|
530
|
+
`${roots.length} top-level nodes; only the first element is connected here. ` +
|
|
531
|
+
'See https://github.com/piconic-ai/barefootjs/issues/2733',
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
rowMount.container.insertBefore(element, rowMount.anchor)
|
|
536
|
+
}
|
|
537
|
+
} else if (fragmentComments && !rootIsDeferredPlaceholder) {
|
|
538
|
+
// Neither a placeholder nor an ambient row position: the caller owns
|
|
539
|
+
// connecting the result itself (e.g. the compiler's exported
|
|
540
|
+
// `export function Name(props, key) { return createComponent(...) }`
|
|
541
|
+
// shim, called directly with no further composition — the shape
|
|
542
|
+
// `fixture-host.ts`'s `'csr-mount'` boot script uses). Bundle every
|
|
543
|
+
// node in one `DocumentFragment` so a plain `container.append(result)`
|
|
544
|
+
// / `el.replaceWith(result)` moves all of them together —
|
|
545
|
+
// `createComponent`'s docstring covers why this is the one shape that
|
|
546
|
+
// can't return a bare `HTMLElement`.
|
|
547
|
+
bareFragment = document.createDocumentFragment()
|
|
548
|
+
bareFragment.append(fragmentComments.start, ...roots, fragmentComments.end)
|
|
328
549
|
}
|
|
329
550
|
|
|
330
551
|
// 8. Set currentScope so provideContext/useContext are element-scoped.
|
|
@@ -390,7 +611,36 @@ function materializeComponent(
|
|
|
390
611
|
// 12. Mark element as initialized
|
|
391
612
|
hydratedScopes.add(element)
|
|
392
613
|
|
|
393
|
-
|
|
614
|
+
// `bareFragment` bundles `element` with its boundary comments for the
|
|
615
|
+
// one shape with no known destination (7b) — everything else returns
|
|
616
|
+
// the real, single element unchanged.
|
|
617
|
+
return bareFragment ?? element
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Splice `attrs` (e.g. ` data-key="1"`) onto `html`'s first element's own
|
|
622
|
+
* tag, wherever that tag starts (templates may open with comment markers
|
|
623
|
+
* like `<!--bf-cond-start:...-->` before the first real element). No-op —
|
|
624
|
+
* returns `html` unchanged — if no element tag is found at all.
|
|
625
|
+
*/
|
|
626
|
+
/**
|
|
627
|
+
* A tag name is not `\\w+`: custom elements are required to contain a
|
|
628
|
+
* hyphen (`<my-widget>`), and `.` and `:` are legal too. Matching only
|
|
629
|
+
* `[A-Za-z0-9_]` spliced attributes into the MIDDLE of such a name
|
|
630
|
+
* (`<my bf-s="…"-widget>`), which the parser then drops entirely — while
|
|
631
|
+
* SSR, which places the same attributes as a compiler-emitted JSX spread,
|
|
632
|
+
* kept emitting them correctly. Anchored on a leading letter so the
|
|
633
|
+
* comment markers a template may open with (`<!--bf-cond-start:…-->`) are
|
|
634
|
+
* still skipped rather than matched.
|
|
635
|
+
*/
|
|
636
|
+
const FIRST_TAG_PATTERN = /<([a-zA-Z][^\s/>]*)/
|
|
637
|
+
const TAG_HEAD_PATTERN = /^(<[a-zA-Z][^\s/>]*)/
|
|
638
|
+
|
|
639
|
+
function spliceAttrsAfterFirstTag(html: string, attrs: string): string {
|
|
640
|
+
const firstElMatch = html.match(FIRST_TAG_PATTERN)
|
|
641
|
+
if (!firstElMatch) return html
|
|
642
|
+
const insertPos = firstElMatch.index!
|
|
643
|
+
return html.slice(0, insertPos) + html.slice(insertPos).replace(TAG_HEAD_PATTERN, `$1${attrs}`)
|
|
394
644
|
}
|
|
395
645
|
|
|
396
646
|
/**
|
|
@@ -425,20 +675,38 @@ export function renderChild(
|
|
|
425
675
|
// `integrations/shared/e2e/toggle.spec.ts` asserts. The def carries the
|
|
426
676
|
// plain name for exactly this — see `ComponentDef.name`, "Used for scope
|
|
427
677
|
// ID generation" (#2518).
|
|
428
|
-
const
|
|
678
|
+
const def = getRegisteredDef(name)
|
|
679
|
+
const displayName = def?.name ?? name
|
|
680
|
+
// A genuine fragment-root child (#2722) carries NO `bf-s`/`bf-h`/`bf-m`
|
|
681
|
+
// element attributes at all — SSR moves them into a wrapping
|
|
682
|
+
// `<!--bf-scope:-->` comment instead (`wrapWithScopeComment`, hono-
|
|
683
|
+
// adapter.ts). Below, `isFragmentRoot` skips the attribute-splicing path
|
|
684
|
+
// entirely and wraps the child's markup in the same comment shape —
|
|
685
|
+
// otherwise every fragment-root child rendered inline by a PARENT's own
|
|
686
|
+
// template (as opposed to a fresh top-level `createComponent()` mount,
|
|
687
|
+
// `materializeComponent`'s equivalent fix) kept stamping `bf-s` onto an
|
|
688
|
+
// element the SSR/hydrate reference never puts one on.
|
|
689
|
+
const isFragmentRoot = def?.fragmentRoot === true
|
|
429
690
|
const scopePrefix = (_parentScopeId && slotSuffix)
|
|
430
691
|
? _parentScopeId
|
|
431
692
|
: `${displayName}_${generateId()}`
|
|
693
|
+
const scopeId = `${scopePrefix}${suffix}`
|
|
432
694
|
const keyAttr = key !== undefined ? ` ${BF_KEY}="${key}"` : ''
|
|
433
695
|
// Slot-relationship markers — only emitted when both host and slot are
|
|
434
696
|
// known; top-level renders without parent context omit them.
|
|
435
697
|
const slotAttrs = (_parentScopeId && slotSuffix)
|
|
436
698
|
? ` ${BF_HOST}="${_parentScopeId}" ${BF_AT}="${slotSuffix}"`
|
|
437
699
|
: ''
|
|
438
|
-
const bfsAttr = `${BF_SCOPE}="${
|
|
700
|
+
const bfsAttr = `${BF_SCOPE}="${scopeId}"`
|
|
439
701
|
|
|
440
702
|
if (!templateFn) {
|
|
441
|
-
|
|
703
|
+
// No template registered: same empty-shell fallback either way, but a
|
|
704
|
+
// fragment-root child still gets its comment pair instead of `bf-s` —
|
|
705
|
+
// an empty `<div></div>` with no scope marker at all would be
|
|
706
|
+
// unfindable by any later `$c()` lookup.
|
|
707
|
+
return isFragmentRoot
|
|
708
|
+
? `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}--><div></div><!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
|
|
709
|
+
: `<div ${bfsAttr}${slotAttrs}${keyAttr}></div>`
|
|
442
710
|
}
|
|
443
711
|
|
|
444
712
|
// Push `_parentScopeId` to THIS child's own derived scope while its
|
|
@@ -469,9 +737,29 @@ export function renderChild(
|
|
|
469
737
|
PLACEHOLDER_ATTR_PATTERN,
|
|
470
738
|
_parentScopeId ? ` bf-s="${_parentScopeId}"` : '',
|
|
471
739
|
)
|
|
740
|
+
|
|
741
|
+
// Fragment-root child (#2722): wrap the whole rendered markup in the
|
|
742
|
+
// SSR/hydrate boundary-comment shape instead of splicing `bf-s`/`bf-h`/
|
|
743
|
+
// `bf-m` into a first element that, structurally, owns none of them —
|
|
744
|
+
// `wrapWithScopeComment`'s CSR mirror, same as `materializeComponent`'s
|
|
745
|
+
// fix for a top-level mount.
|
|
746
|
+
if (isFragmentRoot) {
|
|
747
|
+
const hostSuffix = (_parentScopeId && slotSuffix) ? `|h=${_parentScopeId}|m=${slotSuffix}` : ''
|
|
748
|
+
// #2732: `data-key` for a fragment-root loop row lands on the row's own
|
|
749
|
+
// first element — the same "first element, not first node" convention
|
|
750
|
+
// `IRElement.keyAttr` uses on the SSR side (jsx-to-ir.ts) — not on the
|
|
751
|
+
// comment above, which carries scope identity only. This keeps
|
|
752
|
+
// `mapArray`'s existing `primaryEl.dataset.key` read (map-array.ts)
|
|
753
|
+
// working unchanged for markup this function pre-builds (the pure-CSR
|
|
754
|
+
// `materializeComponent` template-string path, used when there is no
|
|
755
|
+
// SSR content to hydrate against).
|
|
756
|
+
const keyedHtml = keyAttr ? spliceAttrsAfterFirstTag(html, keyAttr) : html
|
|
757
|
+
return `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}${hostSuffix}-->${keyedHtml}<!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
|
|
758
|
+
}
|
|
759
|
+
|
|
472
760
|
// Templates may start with comment markers (e.g. <!--bf-cond-start:...-->)
|
|
473
761
|
// so we find the first element tag rather than assuming index 0.
|
|
474
|
-
const firstElMatch = html.match(
|
|
762
|
+
const firstElMatch = html.match(FIRST_TAG_PATTERN)
|
|
475
763
|
if (!firstElMatch) return html
|
|
476
764
|
const insertPos = firstElMatch.index!
|
|
477
765
|
// Dedupe `bf-s` only when the template body's root already carries
|
|
@@ -484,10 +772,10 @@ export function renderChild(
|
|
|
484
772
|
if (ROOT_HAS_BFS_PATTERN.test(afterInsert)) {
|
|
485
773
|
if (!extraAttrs) return html
|
|
486
774
|
return html.slice(0, insertPos) +
|
|
487
|
-
afterInsert.replace(
|
|
775
|
+
afterInsert.replace(TAG_HEAD_PATTERN, `$1${extraAttrs}`)
|
|
488
776
|
}
|
|
489
777
|
return html.slice(0, insertPos) +
|
|
490
|
-
afterInsert.replace(
|
|
778
|
+
afterInsert.replace(TAG_HEAD_PATTERN, `$1 ${bfsAttr}${extraAttrs}`)
|
|
491
779
|
}
|
|
492
780
|
|
|
493
781
|
// The leading `\s+` is part of the match so dropping the attribute
|
|
@@ -506,11 +794,11 @@ function generateId(): string {
|
|
|
506
794
|
/**
|
|
507
795
|
* Create a placeholder element when template is not found
|
|
508
796
|
*/
|
|
509
|
-
function createPlaceholder(name: string, key?: string | number): HTMLElement {
|
|
797
|
+
function createPlaceholder(name: string, key?: string | number, keyAttrName: string = BF_KEY): HTMLElement {
|
|
510
798
|
const el = document.createElement('div')
|
|
511
799
|
el.setAttribute(BF_SCOPE, `${name}_placeholder`)
|
|
512
800
|
if (key !== undefined) {
|
|
513
|
-
el.setAttribute(
|
|
801
|
+
el.setAttribute(keyAttrName, String(key))
|
|
514
802
|
}
|
|
515
803
|
el.textContent = `[${name}]`
|
|
516
804
|
el.style.cssText = 'color: red; border: 1px dashed red; padding: 4px;'
|
|
@@ -686,6 +974,34 @@ export function escapeTextOrMarkup(value: unknown): string {
|
|
|
686
974
|
return escapeText(value)
|
|
687
975
|
}
|
|
688
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Nullish guard for a bare `${children}` passthrough splice
|
|
979
|
+
* (`ir-to-client-js/html-template.ts`'s no-`slotId` `'expression'` branches
|
|
980
|
+
* — the "bare `${...}` interpolations" the docstring above `escapeTextSlotExpr`
|
|
981
|
+
* describes) — #2775. The value here is ALREADY-STRINGIFIED MARKUP, not an
|
|
982
|
+
* arbitrary prop: `materializeComponent` (this file, "Template functions
|
|
983
|
+
* expect children as an HTML string, not an array") joins a component's
|
|
984
|
+
* `children` into an HTML string before the template lambda ever runs, so by
|
|
985
|
+
* the time this function sees the value it is either that HTML string or,
|
|
986
|
+
* when the caller passed none, simply `undefined` (the key is absent from
|
|
987
|
+
* the props object entirely). This function's ENTIRE job is that one
|
|
988
|
+
* nullish case: turn the absent/`null` value into `''`, same as SSR's
|
|
989
|
+
* `{props.children}` and the DSL adapters' `bf.string(children)` render it.
|
|
990
|
+
*
|
|
991
|
+
* Deliberately NOT `escapeTextOrMarkup`: that function falls back to
|
|
992
|
+
* `escapeText` for anything not `bfMarkup()`-branded, and a `children`
|
|
993
|
+
* string is never branded (it is a plain string built by
|
|
994
|
+
* `materializeComponent`, not compiler-escaped piece-by-piece the way a
|
|
995
|
+
* `bfMarkup()` value is) — routing `children` through it would HTML-escape
|
|
996
|
+
* real markup into visible `<span>`-style text on every call that
|
|
997
|
+
* actually has children, trading the no-children bug for a strictly worse
|
|
998
|
+
* with-children one. So: nullish becomes `''`; every other value is
|
|
999
|
+
* returned completely unescaped, exactly as it arrived.
|
|
1000
|
+
*/
|
|
1001
|
+
export function markupOrEmpty(value: unknown): string {
|
|
1002
|
+
return value == null ? '' : (value as string)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
689
1005
|
/**
|
|
690
1006
|
* `escapeText`'s counterpart for a claimed 'markup' slot's REACTIVE write
|
|
691
1007
|
* (slot unification A3 follow-up), where the value is a plain-JS expression
|
|
@@ -810,6 +1126,7 @@ function createComponentFromDef(
|
|
|
810
1126
|
key?: string | number,
|
|
811
1127
|
mountAt?: Element | null,
|
|
812
1128
|
rowMount?: { container: Node; anchor: Node | null } | null,
|
|
1129
|
+
keyAttrName: string = BF_KEY,
|
|
813
1130
|
): HTMLElement {
|
|
814
1131
|
if (!def.template) {
|
|
815
1132
|
throw new Error('[BarefootJS] createComponent with ComponentDef requires a template function')
|
|
@@ -834,7 +1151,7 @@ function createComponentFromDef(
|
|
|
834
1151
|
const scopeId = `${name}_${generateId()}`
|
|
835
1152
|
element.setAttribute(BF_SCOPE, scopeId)
|
|
836
1153
|
if (key !== undefined) {
|
|
837
|
-
element.setAttribute(
|
|
1154
|
+
element.setAttribute(keyAttrName, String(key))
|
|
838
1155
|
}
|
|
839
1156
|
|
|
840
1157
|
// Connect before init, for the same reason the registry path does (see
|
package/src/runtime/hydrate.ts
CHANGED
|
@@ -422,8 +422,17 @@ function hydrateCommentScope(comment: Comment): void {
|
|
|
422
422
|
|
|
423
423
|
commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId })
|
|
424
424
|
|
|
425
|
-
|
|
426
|
-
|
|
425
|
+
// The JSON is the scope's OWN flat props object, not namespaced under
|
|
426
|
+
// the component name — `wrapWithScopeComment` (hono-adapter.ts) emits
|
|
427
|
+
// `__bfPropsJson` verbatim after the `|`, exactly like `hydrateElementScope`
|
|
428
|
+
// reads `bf-p` directly below with no unwrap step. An earlier `parsed[name]
|
|
429
|
+
// ?? {}` here assumed a `{ [name]: props }` shape that no emitter ever
|
|
430
|
+
// produces, so every root fragment scope with props hydrated against an
|
|
431
|
+
// empty object (#2721) — invisible whenever `currentComponentHasProps` was
|
|
432
|
+
// false (no props segment emitted at all, so `{}` was already correct),
|
|
433
|
+
// which is why this survived until the mutation sweep's fragment-wrap
|
|
434
|
+
// exercised a component that actually depends on its props at hydration.
|
|
435
|
+
const props = parseProps(propsJson || null, `comment scope ${scopeId}`)
|
|
427
436
|
runInit(proxyEl, def, props)
|
|
428
437
|
}
|
|
429
438
|
|
package/src/runtime/index.ts
CHANGED
|
@@ -166,6 +166,11 @@ export interface LazyRowPlan<T> {
|
|
|
166
166
|
* @param plan - Compiler-emitted row plan (see {@link LazyRowPlan})
|
|
167
167
|
* @param markerId - Scoped loop marker id (`<!--bf-loop:<id>-->`), see #1087
|
|
168
168
|
* @param bfId - Profiler attribution id for the reconciler effect
|
|
169
|
+
* @param keyAttrName - The row-key attribute NAME this loop resolved at
|
|
170
|
+
* compile time (`IRElement.keyAttr`/`keyAttrName(loop.depth)`,
|
|
171
|
+
* jsx-to-ir.ts). Defaults to `BF_KEY` (plain `'data-key'`),
|
|
172
|
+
* correct at the outermost loop; only a nested loop's
|
|
173
|
+
* compiled call passes a depth-suffixed name (#2753).
|
|
169
174
|
*/
|
|
170
175
|
export function mapArrayLazy<T>(
|
|
171
176
|
accessor: () => T[],
|
|
@@ -174,6 +179,7 @@ export function mapArrayLazy<T>(
|
|
|
174
179
|
plan: LazyRowPlan<T>,
|
|
175
180
|
markerId?: string,
|
|
176
181
|
bfId?: string,
|
|
182
|
+
keyAttrName: string = BF_KEY,
|
|
177
183
|
): void {
|
|
178
184
|
if (!container) return
|
|
179
185
|
|
|
@@ -249,8 +255,11 @@ export function mapArrayLazy<T>(
|
|
|
249
255
|
* so it is wrapped in `untrack()`: it writes outer-involving bindings
|
|
250
256
|
* with current values (contract), and those signal reads must not
|
|
251
257
|
* subscribe the reconciler. The returned element is assigned to
|
|
252
|
-
* `entry.primaryEl`;
|
|
253
|
-
* (mirrors `mapArray`'s create path)
|
|
258
|
+
* `entry.primaryEl`; the row's key attribute is stamped if `createRow`
|
|
259
|
+
* didn't (mirrors `mapArray`'s create path) — only for a KEYED loop
|
|
260
|
+
* (#2753 Shape A: an unkeyed loop's rows carry no key attribute at all),
|
|
261
|
+
* using `keyAttrName` (Shape B: a nested loop's name is depth-suffixed,
|
|
262
|
+
* never the plain default `BF_KEY`).
|
|
254
263
|
*/
|
|
255
264
|
const createEntry = (item: T, index: number, key: string): LazyRowEntry<T> => {
|
|
256
265
|
const entry: LazyRowEntry<T> = {
|
|
@@ -263,7 +272,7 @@ export function mapArrayLazy<T>(
|
|
|
263
272
|
last: null,
|
|
264
273
|
}
|
|
265
274
|
entry.primaryEl = untrack(() => plan.createRow(entry, index))
|
|
266
|
-
if (!entry.primaryEl.
|
|
275
|
+
if (getKey && !entry.primaryEl.getAttribute(keyAttrName)) entry.primaryEl.setAttribute(keyAttrName, key)
|
|
267
276
|
markStranded()
|
|
268
277
|
return entry
|
|
269
278
|
}
|
|
@@ -294,7 +303,9 @@ export function mapArrayLazy<T>(
|
|
|
294
303
|
const el = doms[i]
|
|
295
304
|
// READ the SSR-rendered key (never write it on adopted rows);
|
|
296
305
|
// positional item pairing is sound by the §9.3(2) eligibility gate.
|
|
297
|
-
|
|
306
|
+
// `keyAttrName`, not the plain `BF_KEY` default — a nested loop's
|
|
307
|
+
// rows carry a depth-suffixed name (#2753 Shape B).
|
|
308
|
+
const ssrKey = el.getAttribute(keyAttrName)
|
|
298
309
|
const key = ssrKey !== null ? ssrKey : getKey ? getKey(items[i], i) : String(i)
|
|
299
310
|
const entry: LazyRowEntry<T> = { key, primaryEl: el, item: items[i], refs: null, last: null }
|
|
300
311
|
entries.set(key, entry)
|