@barefootjs/client 0.33.1 → 0.33.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/csr-adapter.js +4 -0
- package/dist/runtime/component.d.ts +27 -1
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/index.js +123 -31
- 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 +123 -31
- 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 +285 -22
- package/src/runtime/hydrate.ts +11 -2
- package/src/runtime/map-array.ts +136 -21
- 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,13 +147,52 @@ 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
|
+
): HTMLElement
|
|
182
|
+
export function createComponent(
|
|
183
|
+
nameOrDef: string | ComponentDef,
|
|
184
|
+
props?: Record<string, unknown>,
|
|
185
|
+
key?: string | number,
|
|
186
|
+
slot?: CreateComponentSlotInfo,
|
|
187
|
+
mountAt?: Element | null,
|
|
188
|
+
): HTMLElement | DocumentFragment
|
|
140
189
|
export function createComponent(
|
|
141
190
|
nameOrDef: string | ComponentDef,
|
|
142
191
|
props: Record<string, unknown> = {},
|
|
143
192
|
key?: string | number,
|
|
144
193
|
slot?: CreateComponentSlotInfo,
|
|
145
194
|
mountAt?: Element | null,
|
|
146
|
-
): HTMLElement {
|
|
195
|
+
): HTMLElement | DocumentFragment {
|
|
147
196
|
const element = materializeComponent(nameOrDef, props, key, slot, mountAt)
|
|
148
197
|
// `mountAt` is an unconditional obligation: callers used to run
|
|
149
198
|
// `ph.replaceWith(comp)` themselves on every outcome, so every path that
|
|
@@ -152,7 +201,9 @@ export function createComponent(
|
|
|
152
201
|
// which must stay detached so its self-replacement stays recoverable.
|
|
153
202
|
// `parentNode` (not `isConnected`) is the right "still unconsumed" probe: it
|
|
154
203
|
// survives a `mountAt` that was itself detached, which is the normal case
|
|
155
|
-
// during multi-root loop-body setup.
|
|
204
|
+
// during multi-root loop-body setup. A fragment-root's `DocumentFragment`
|
|
205
|
+
// return only ever happens when `mountAt` is absent (see above), so it
|
|
206
|
+
// never reaches this branch.
|
|
156
207
|
if (mountAt && mountAt.parentNode && element !== mountAt) {
|
|
157
208
|
mountAt.replaceWith(element)
|
|
158
209
|
}
|
|
@@ -173,7 +224,7 @@ function materializeComponent(
|
|
|
173
224
|
key?: string | number,
|
|
174
225
|
slot?: CreateComponentSlotInfo,
|
|
175
226
|
mountAt?: Element | null,
|
|
176
|
-
): HTMLElement {
|
|
227
|
+
): HTMLElement | DocumentFragment {
|
|
177
228
|
// A bare callable shim invoked from user code (e.g. an object-literal
|
|
178
229
|
// value `LOGOS[id]()` whose arrow the compiler hoisted into a component)
|
|
179
230
|
// reaches us with no props (#1663). Normalize to an empty object so the
|
|
@@ -230,11 +281,22 @@ function materializeComponent(
|
|
|
230
281
|
|
|
231
282
|
// 4. Pre-generate the component's scope ID.
|
|
232
283
|
//
|
|
233
|
-
// `comment: true` components
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
284
|
+
// `comment: true` components are proxy-scoped — no element of their own
|
|
285
|
+
// carries `bf-s` directly — but that covers TWO different shapes
|
|
286
|
+
// (`ComponentDef.fragmentRoot`'s docstring, types.ts) that need OPPOSITE
|
|
287
|
+
// treatment here:
|
|
288
|
+
// - root-is-a-child-call (#1211/#2649, `fragmentRoot` false): the
|
|
289
|
+
// parsed `firstChild` IS the child's own already-scoped element.
|
|
290
|
+
// Don't overwrite it (scopeId stays null), or `$c(__scope, 's0')`
|
|
291
|
+
// from the wrapper's init resolves to null.
|
|
292
|
+
// - genuine fragment root (`fragmentRoot` true): the parsed `firstChild`
|
|
293
|
+
// carries NO scope of its own (SSR moves it into the wrapping
|
|
294
|
+
// `<!--bf-scope:-->` comment) — generate one just the same, so
|
|
295
|
+
// `_parentScopeId` below still gets threaded into nested
|
|
296
|
+
// `renderChild()` calls and their naming matches SSR/hydrate (#2722:
|
|
297
|
+
// leaving this null made every nested child fall back to a random,
|
|
298
|
+
// un-prefixed scope id — `Select_xyz` instead of the expected
|
|
299
|
+
// `SelectBasicDemo_xyz_s8`).
|
|
238
300
|
//
|
|
239
301
|
// `slot` is only supplied by `upsertChild` / `upsertChildItem` mounting a
|
|
240
302
|
// component nested below a loop row root — the SSR reference (Hono)
|
|
@@ -245,11 +307,12 @@ function materializeComponent(
|
|
|
245
307
|
// random id, matching the reference behaviour.
|
|
246
308
|
const def = getRegisteredDef(name)
|
|
247
309
|
const isCommentWrapper = def?.comment === true
|
|
310
|
+
const isFragmentRoot = def?.fragmentRoot === true
|
|
248
311
|
const derivedScopeId = slot?.parent && slot.mount ? `${slot.parent}_${slot.mount}` : null
|
|
249
312
|
// Same as in `renderChild`: `name` is the registry key, which is
|
|
250
313
|
// file-scoped (`Name__<8hex>`) for a non-exported component. The scope ID
|
|
251
314
|
// must carry the plain name — see `ComponentDef.name` (#2518).
|
|
252
|
-
const scopeId = isCommentWrapper ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
|
|
315
|
+
const scopeId = (isCommentWrapper && !isFragmentRoot) ? null : (derivedScopeId ?? `${def?.name ?? name}_${generateId()}`)
|
|
253
316
|
|
|
254
317
|
// 5. Generate HTML from props.
|
|
255
318
|
//
|
|
@@ -278,8 +341,52 @@ function materializeComponent(
|
|
|
278
341
|
_parentScopeId = prevParentScopeId
|
|
279
342
|
}
|
|
280
343
|
|
|
281
|
-
// 6. Create DOM
|
|
282
|
-
|
|
344
|
+
// 6. Create DOM node(s).
|
|
345
|
+
//
|
|
346
|
+
// A genuine fragment root's template concatenates EVERY top-level
|
|
347
|
+
// sibling into one HTML string, so `roots` is the whole ordered list —
|
|
348
|
+
// `parseHTML(...).firstChild` used to be the only node kept, silently
|
|
349
|
+
// dropping the rest (#2735). Everything travels, whatever its node
|
|
350
|
+
// type: a fragment's top level is not only elements. Bare text between
|
|
351
|
+
// two element roots (`<><h1/>text<p/></>`) is a root, and a reactive
|
|
352
|
+
// text slot sitting there renders as a `<!--bf:sN-->` marker. Both were
|
|
353
|
+
// measured being dropped by an element-only walk — the text as a
|
|
354
|
+
// visible SSR/CSR-mount diff, the marker as something worse, since the
|
|
355
|
+
// runtime's own slot lookup then finds nothing to bind.
|
|
356
|
+
//
|
|
357
|
+
// `element` is the PROXY: the one node threaded through init /
|
|
358
|
+
// `commentScopeRegistry` / the return value. It must be an Element —
|
|
359
|
+
// everything downstream calls `setAttribute`/`hasAttribute` on it — so
|
|
360
|
+
// it is the first ELEMENT among the roots, not simply the first node.
|
|
361
|
+
// `<>text<p/></>` puts a Text node first, and taking that as the proxy
|
|
362
|
+
// threw `element.hasAttribute is not a function` at step 7b (measured;
|
|
363
|
+
// pre-dates #2735's fix, which is why the roots list and the proxy are
|
|
364
|
+
// chosen separately rather than the proxy being `roots[0]`).
|
|
365
|
+
//
|
|
366
|
+
// Only `isFragmentRoot` templates can emit more than one top-level node
|
|
367
|
+
// (jsx-to-ir.ts's `transformFragment`), so every other shape keeps
|
|
368
|
+
// exactly the single-node list it always had.
|
|
369
|
+
const parsedFragment = parseHTML(html.trim())
|
|
370
|
+
const roots: Node[] = isFragmentRoot
|
|
371
|
+
? Array.from(parsedFragment.childNodes)
|
|
372
|
+
: parsedFragment.firstChild
|
|
373
|
+
? [parsedFragment.firstChild]
|
|
374
|
+
: []
|
|
375
|
+
const element = (isFragmentRoot
|
|
376
|
+
? roots.find(node => node.nodeType === Node.ELEMENT_NODE)
|
|
377
|
+
: roots[0]) as HTMLElement | undefined
|
|
378
|
+
|
|
379
|
+
// A fragment root with no element at all (`<>just text</>`) has nothing
|
|
380
|
+
// that can carry a scope. Refuse it the same way an empty template is
|
|
381
|
+
// refused rather than crashing on the first `setAttribute` — loud, not
|
|
382
|
+
// silent, per the sound-or-loud rule.
|
|
383
|
+
if (isFragmentRoot && roots.length > 0 && !element) {
|
|
384
|
+
console.warn(
|
|
385
|
+
`[BarefootJS] Fragment-root component ${name} rendered no element root; ` +
|
|
386
|
+
'a scope needs at least one element to attach to. Wrap the content in an element.',
|
|
387
|
+
)
|
|
388
|
+
return createPlaceholder(name, key)
|
|
389
|
+
}
|
|
283
390
|
|
|
284
391
|
if (!element) {
|
|
285
392
|
console.warn(`[BarefootJS] Template returned empty HTML for component: ${name}`)
|
|
@@ -287,7 +394,13 @@ function materializeComponent(
|
|
|
287
394
|
}
|
|
288
395
|
|
|
289
396
|
// 7. Set scope ID and key attributes.
|
|
290
|
-
|
|
397
|
+
//
|
|
398
|
+
// A genuine fragment root carries its scope id on a WRAPPING comment
|
|
399
|
+
// pair, never as a `bf-s` attribute on the element itself — matching
|
|
400
|
+
// `wrapWithScopeComment` (hono-adapter.ts) and `hydrateCommentScope`
|
|
401
|
+
// (hydrate.ts). `scopeId` is still non-null for this shape (step 4) so
|
|
402
|
+
// `_parentScopeId` threads correctly; only the ATTRIBUTE is skipped here.
|
|
403
|
+
if (scopeId && !isFragmentRoot) {
|
|
291
404
|
element.setAttribute(BF_SCOPE, scopeId)
|
|
292
405
|
}
|
|
293
406
|
if (slot) {
|
|
@@ -298,6 +411,27 @@ function materializeComponent(
|
|
|
298
411
|
element.setAttribute(BF_KEY, String(key))
|
|
299
412
|
}
|
|
300
413
|
|
|
414
|
+
// 7a. Fragment-root boundary comments + registry (#2722).
|
|
415
|
+
//
|
|
416
|
+
// `find()`/`$()`/`$c()` (query.ts) resolve a slot or child scope by
|
|
417
|
+
// walking `commentScopeRegistry`'s stored comment and its boundary
|
|
418
|
+
// (`getCommentScopeBoundary`, scope.ts) — that walk needs REAL, sibling-
|
|
419
|
+
// connected comment nodes, not just a registry entry, or `find()`'s
|
|
420
|
+
// comment-scope branch enumerates zero candidates (worse than the
|
|
421
|
+
// fallback `querySelectorAll` path a non-fragment scope gets). So these
|
|
422
|
+
// are built now and threaded through to wherever `element` ends up
|
|
423
|
+
// connected below, exactly mirroring the SSR/hydrate shape:
|
|
424
|
+
// <!--bf-scope:ID-->` + element + `<!--bf-/scope:ID-->`
|
|
425
|
+
const fragmentComments = isFragmentRoot && scopeId
|
|
426
|
+
? {
|
|
427
|
+
start: document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`),
|
|
428
|
+
end: document.createComment(`${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}`),
|
|
429
|
+
}
|
|
430
|
+
: null
|
|
431
|
+
if (fragmentComments) {
|
|
432
|
+
commentScopeRegistry.set(element, { commentNode: fragmentComments.start, scopeId: scopeId! })
|
|
433
|
+
}
|
|
434
|
+
|
|
301
435
|
// 7b. Connect before init.
|
|
302
436
|
//
|
|
303
437
|
// `initFn` resolves context by DOM position (`useContext` walks
|
|
@@ -317,14 +451,76 @@ function materializeComponent(
|
|
|
317
451
|
// live DOM with no handle on the result, so this shape keeps the
|
|
318
452
|
// detached behaviour.
|
|
319
453
|
const rootIsDeferredPlaceholder = element.hasAttribute(BF_PLACEHOLDER)
|
|
454
|
+
// A fragment root's boundary comments must land adjacent to `element`
|
|
455
|
+
// at the SAME moment it connects, in each of the three shapes below —
|
|
456
|
+
// there is no later hook to attach them once the caller has taken the
|
|
457
|
+
// return value away (see `createComponent`'s docstring for the fourth,
|
|
458
|
+
// no-known-destination shape, handled after `init` runs).
|
|
459
|
+
let bareFragment: DocumentFragment | null = null
|
|
320
460
|
if (mountAt && !rootIsDeferredPlaceholder) {
|
|
321
|
-
|
|
461
|
+
if (fragmentComments) {
|
|
462
|
+
mountAt.replaceWith(fragmentComments.start, ...roots, fragmentComments.end)
|
|
463
|
+
} else {
|
|
464
|
+
mountAt.replaceWith(element)
|
|
465
|
+
}
|
|
322
466
|
} else if (rowMount && !rootIsDeferredPlaceholder) {
|
|
323
467
|
// Loop row: no placeholder exists, so connect at the position `mapArray`
|
|
324
468
|
// handed down. The reorder step may move the row afterwards; any position
|
|
325
469
|
// inside the container yields the same ancestor chain, which is all
|
|
326
470
|
// `useContext`'s parentElement walk needs.
|
|
327
|
-
|
|
471
|
+
if (fragmentComments) {
|
|
472
|
+
rowMount.container.insertBefore(fragmentComments.start, rowMount.anchor)
|
|
473
|
+
rowMount.container.insertBefore(element, rowMount.anchor)
|
|
474
|
+
rowMount.container.insertBefore(fragmentComments.end, rowMount.anchor)
|
|
475
|
+
// Hand the boundary pair to `mapArray`'s row bookkeeping (map-array.ts's
|
|
476
|
+
// `ItemScope.scopeComments`, #2733) via the same stash-on-the-element
|
|
477
|
+
// convention `__bfExtras` uses for a multi-root loop BODY's extra
|
|
478
|
+
// siblings: `createItemScope` reads and deletes this property right
|
|
479
|
+
// after `renderItem` returns, since there is no other channel back to
|
|
480
|
+
// the caller once `element` is the only thing returned below. Without
|
|
481
|
+
// this, a later reorder/removal of the row moves/removes `element`
|
|
482
|
+
// and leaves the comments behind, orphaned in the container.
|
|
483
|
+
;(element as unknown as { __bfScopeComments?: { start: Comment; end: Comment } }).__bfScopeComments =
|
|
484
|
+
fragmentComments
|
|
485
|
+
// Deliberately NOT inserting the other roots here (a fragment-root
|
|
486
|
+
// component whose OWN render has 2+ top-level nodes, used as a loop
|
|
487
|
+
// row) — connecting them is a separate gap from the boundary-comment
|
|
488
|
+
// tracking #2733 fixed above: even with `ItemScope` now able to carry
|
|
489
|
+
// the row's comments, there is still nowhere on `ItemScope` for a
|
|
490
|
+
// second or third top-level ELEMENT of the row itself (as opposed to
|
|
491
|
+
// `extras`, which is the multi-root loop BODY's own, unrelated,
|
|
492
|
+
// per-item marker convention). Not reachable by any currently tracked
|
|
493
|
+
// fixture (no fragment-root component with 2+ top-level nodes is used
|
|
494
|
+
// as a loop row in the mutation corpus), so declared rather than grown
|
|
495
|
+
// here:
|
|
496
|
+
// https://github.com/piconic-ai/barefootjs/issues/2733
|
|
497
|
+
//
|
|
498
|
+
// Loud, not silent: the whole point of the fix above is that
|
|
499
|
+
// dropping roots without saying so is the failure mode. A gap that
|
|
500
|
+
// stays quiet is indistinguishable from correctness at the call
|
|
501
|
+
// site, so the one shape still dropping them says so.
|
|
502
|
+
if (roots.length > 1) {
|
|
503
|
+
console.warn(
|
|
504
|
+
`[BarefootJS] Fragment-root component ${name} used as a loop row renders ` +
|
|
505
|
+
`${roots.length} top-level nodes; only the first element is connected here. ` +
|
|
506
|
+
'See https://github.com/piconic-ai/barefootjs/issues/2733',
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
rowMount.container.insertBefore(element, rowMount.anchor)
|
|
511
|
+
}
|
|
512
|
+
} else if (fragmentComments && !rootIsDeferredPlaceholder) {
|
|
513
|
+
// Neither a placeholder nor an ambient row position: the caller owns
|
|
514
|
+
// connecting the result itself (e.g. the compiler's exported
|
|
515
|
+
// `export function Name(props, key) { return createComponent(...) }`
|
|
516
|
+
// shim, called directly with no further composition — the shape
|
|
517
|
+
// `fixture-host.ts`'s `'csr-mount'` boot script uses). Bundle every
|
|
518
|
+
// node in one `DocumentFragment` so a plain `container.append(result)`
|
|
519
|
+
// / `el.replaceWith(result)` moves all of them together —
|
|
520
|
+
// `createComponent`'s docstring covers why this is the one shape that
|
|
521
|
+
// can't return a bare `HTMLElement`.
|
|
522
|
+
bareFragment = document.createDocumentFragment()
|
|
523
|
+
bareFragment.append(fragmentComments.start, ...roots, fragmentComments.end)
|
|
328
524
|
}
|
|
329
525
|
|
|
330
526
|
// 8. Set currentScope so provideContext/useContext are element-scoped.
|
|
@@ -390,7 +586,36 @@ function materializeComponent(
|
|
|
390
586
|
// 12. Mark element as initialized
|
|
391
587
|
hydratedScopes.add(element)
|
|
392
588
|
|
|
393
|
-
|
|
589
|
+
// `bareFragment` bundles `element` with its boundary comments for the
|
|
590
|
+
// one shape with no known destination (7b) — everything else returns
|
|
591
|
+
// the real, single element unchanged.
|
|
592
|
+
return bareFragment ?? element
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Splice `attrs` (e.g. ` data-key="1"`) onto `html`'s first element's own
|
|
597
|
+
* tag, wherever that tag starts (templates may open with comment markers
|
|
598
|
+
* like `<!--bf-cond-start:...-->` before the first real element). No-op —
|
|
599
|
+
* returns `html` unchanged — if no element tag is found at all.
|
|
600
|
+
*/
|
|
601
|
+
/**
|
|
602
|
+
* A tag name is not `\\w+`: custom elements are required to contain a
|
|
603
|
+
* hyphen (`<my-widget>`), and `.` and `:` are legal too. Matching only
|
|
604
|
+
* `[A-Za-z0-9_]` spliced attributes into the MIDDLE of such a name
|
|
605
|
+
* (`<my bf-s="…"-widget>`), which the parser then drops entirely — while
|
|
606
|
+
* SSR, which places the same attributes as a compiler-emitted JSX spread,
|
|
607
|
+
* kept emitting them correctly. Anchored on a leading letter so the
|
|
608
|
+
* comment markers a template may open with (`<!--bf-cond-start:…-->`) are
|
|
609
|
+
* still skipped rather than matched.
|
|
610
|
+
*/
|
|
611
|
+
const FIRST_TAG_PATTERN = /<([a-zA-Z][^\s/>]*)/
|
|
612
|
+
const TAG_HEAD_PATTERN = /^(<[a-zA-Z][^\s/>]*)/
|
|
613
|
+
|
|
614
|
+
function spliceAttrsAfterFirstTag(html: string, attrs: string): string {
|
|
615
|
+
const firstElMatch = html.match(FIRST_TAG_PATTERN)
|
|
616
|
+
if (!firstElMatch) return html
|
|
617
|
+
const insertPos = firstElMatch.index!
|
|
618
|
+
return html.slice(0, insertPos) + html.slice(insertPos).replace(TAG_HEAD_PATTERN, `$1${attrs}`)
|
|
394
619
|
}
|
|
395
620
|
|
|
396
621
|
/**
|
|
@@ -425,20 +650,38 @@ export function renderChild(
|
|
|
425
650
|
// `integrations/shared/e2e/toggle.spec.ts` asserts. The def carries the
|
|
426
651
|
// plain name for exactly this — see `ComponentDef.name`, "Used for scope
|
|
427
652
|
// ID generation" (#2518).
|
|
428
|
-
const
|
|
653
|
+
const def = getRegisteredDef(name)
|
|
654
|
+
const displayName = def?.name ?? name
|
|
655
|
+
// A genuine fragment-root child (#2722) carries NO `bf-s`/`bf-h`/`bf-m`
|
|
656
|
+
// element attributes at all — SSR moves them into a wrapping
|
|
657
|
+
// `<!--bf-scope:-->` comment instead (`wrapWithScopeComment`, hono-
|
|
658
|
+
// adapter.ts). Below, `isFragmentRoot` skips the attribute-splicing path
|
|
659
|
+
// entirely and wraps the child's markup in the same comment shape —
|
|
660
|
+
// otherwise every fragment-root child rendered inline by a PARENT's own
|
|
661
|
+
// template (as opposed to a fresh top-level `createComponent()` mount,
|
|
662
|
+
// `materializeComponent`'s equivalent fix) kept stamping `bf-s` onto an
|
|
663
|
+
// element the SSR/hydrate reference never puts one on.
|
|
664
|
+
const isFragmentRoot = def?.fragmentRoot === true
|
|
429
665
|
const scopePrefix = (_parentScopeId && slotSuffix)
|
|
430
666
|
? _parentScopeId
|
|
431
667
|
: `${displayName}_${generateId()}`
|
|
668
|
+
const scopeId = `${scopePrefix}${suffix}`
|
|
432
669
|
const keyAttr = key !== undefined ? ` ${BF_KEY}="${key}"` : ''
|
|
433
670
|
// Slot-relationship markers — only emitted when both host and slot are
|
|
434
671
|
// known; top-level renders without parent context omit them.
|
|
435
672
|
const slotAttrs = (_parentScopeId && slotSuffix)
|
|
436
673
|
? ` ${BF_HOST}="${_parentScopeId}" ${BF_AT}="${slotSuffix}"`
|
|
437
674
|
: ''
|
|
438
|
-
const bfsAttr = `${BF_SCOPE}="${
|
|
675
|
+
const bfsAttr = `${BF_SCOPE}="${scopeId}"`
|
|
439
676
|
|
|
440
677
|
if (!templateFn) {
|
|
441
|
-
|
|
678
|
+
// No template registered: same empty-shell fallback either way, but a
|
|
679
|
+
// fragment-root child still gets its comment pair instead of `bf-s` —
|
|
680
|
+
// an empty `<div></div>` with no scope marker at all would be
|
|
681
|
+
// unfindable by any later `$c()` lookup.
|
|
682
|
+
return isFragmentRoot
|
|
683
|
+
? `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}--><div></div><!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
|
|
684
|
+
: `<div ${bfsAttr}${slotAttrs}${keyAttr}></div>`
|
|
442
685
|
}
|
|
443
686
|
|
|
444
687
|
// Push `_parentScopeId` to THIS child's own derived scope while its
|
|
@@ -469,9 +712,29 @@ export function renderChild(
|
|
|
469
712
|
PLACEHOLDER_ATTR_PATTERN,
|
|
470
713
|
_parentScopeId ? ` bf-s="${_parentScopeId}"` : '',
|
|
471
714
|
)
|
|
715
|
+
|
|
716
|
+
// Fragment-root child (#2722): wrap the whole rendered markup in the
|
|
717
|
+
// SSR/hydrate boundary-comment shape instead of splicing `bf-s`/`bf-h`/
|
|
718
|
+
// `bf-m` into a first element that, structurally, owns none of them —
|
|
719
|
+
// `wrapWithScopeComment`'s CSR mirror, same as `materializeComponent`'s
|
|
720
|
+
// fix for a top-level mount.
|
|
721
|
+
if (isFragmentRoot) {
|
|
722
|
+
const hostSuffix = (_parentScopeId && slotSuffix) ? `|h=${_parentScopeId}|m=${slotSuffix}` : ''
|
|
723
|
+
// #2732: `data-key` for a fragment-root loop row lands on the row's own
|
|
724
|
+
// first element — the same "first element, not first node" convention
|
|
725
|
+
// `IRElement.carriesDataKey` uses on the SSR side (jsx-to-ir.ts) — not
|
|
726
|
+
// on the comment above, which carries scope identity only. This keeps
|
|
727
|
+
// `mapArray`'s existing `primaryEl.dataset.key` read (map-array.ts)
|
|
728
|
+
// working unchanged for markup this function pre-builds (the pure-CSR
|
|
729
|
+
// `materializeComponent` template-string path, used when there is no
|
|
730
|
+
// SSR content to hydrate against).
|
|
731
|
+
const keyedHtml = keyAttr ? spliceAttrsAfterFirstTag(html, keyAttr) : html
|
|
732
|
+
return `<!--${BF_SCOPE_COMMENT_PREFIX}${scopeId}${hostSuffix}-->${keyedHtml}<!--${BF_SCOPE_COMMENT_END_PREFIX}${scopeId}-->`
|
|
733
|
+
}
|
|
734
|
+
|
|
472
735
|
// Templates may start with comment markers (e.g. <!--bf-cond-start:...-->)
|
|
473
736
|
// so we find the first element tag rather than assuming index 0.
|
|
474
|
-
const firstElMatch = html.match(
|
|
737
|
+
const firstElMatch = html.match(FIRST_TAG_PATTERN)
|
|
475
738
|
if (!firstElMatch) return html
|
|
476
739
|
const insertPos = firstElMatch.index!
|
|
477
740
|
// Dedupe `bf-s` only when the template body's root already carries
|
|
@@ -484,10 +747,10 @@ export function renderChild(
|
|
|
484
747
|
if (ROOT_HAS_BFS_PATTERN.test(afterInsert)) {
|
|
485
748
|
if (!extraAttrs) return html
|
|
486
749
|
return html.slice(0, insertPos) +
|
|
487
|
-
afterInsert.replace(
|
|
750
|
+
afterInsert.replace(TAG_HEAD_PATTERN, `$1${extraAttrs}`)
|
|
488
751
|
}
|
|
489
752
|
return html.slice(0, insertPos) +
|
|
490
|
-
afterInsert.replace(
|
|
753
|
+
afterInsert.replace(TAG_HEAD_PATTERN, `$1 ${bfsAttr}${extraAttrs}`)
|
|
491
754
|
}
|
|
492
755
|
|
|
493
756
|
// The leading `\s+` is part of the match so dropping the attribute
|
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
|
|