@barefootjs/client 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/claim-slots.d.ts +69 -4
- package/dist/runtime/claim-slots.d.ts.map +1 -1
- package/dist/runtime/component.d.ts +11 -1
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +322 -31
- package/dist/runtime/map-array-lazy.d.ts +164 -0
- package/dist/runtime/map-array-lazy.d.ts.map +1 -0
- package/dist/runtime/map-array.d.ts +35 -0
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/qsa-item.d.ts +7 -0
- package/dist/runtime/qsa-item.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/standalone.js +310 -20
- package/package.json +2 -2
- package/src/runtime/claim-slots.ts +212 -14
- package/src/runtime/component.ts +116 -3
- package/src/runtime/index.ts +5 -1
- package/src/runtime/map-array-lazy.ts +470 -0
- package/src/runtime/map-array.ts +68 -11
- package/src/runtime/qsa-item.ts +9 -3
- package/src/runtime/registry.ts +5 -3
|
@@ -25,10 +25,18 @@
|
|
|
25
25
|
* Kind contracts (mirroring the mechanisms being superseded — this is the
|
|
26
26
|
* "one slot concept with an identity contract" of §4):
|
|
27
27
|
* - 'text': held ref is the Text node immediately after the anchor
|
|
28
|
-
* comment
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* comment. The CLAIM is non-mutating: it adopts that node when SSR
|
|
29
|
+
* rendered the slot non-empty and otherwise holds the ANCHOR COMMENT
|
|
30
|
+
* itself as a stand-in for the not-yet-created node, deferring
|
|
31
|
+
* `document.createTextNode` to the first write that actually needs it
|
|
32
|
+
* (`materializeText` reads the insertion point off that comment). That
|
|
33
|
+
* one-field representation is deliberate — see `ClaimedTextSlot`. What
|
|
34
|
+
* lets
|
|
35
|
+
* `read` exist — a seed that only compares must be able to inspect a
|
|
36
|
+
* slot without leaving an empty Text node behind on every row
|
|
37
|
+
* (§9.3(1)). Writes are a `nodeValue` assignment, and once materialized
|
|
38
|
+
* the node's identity never changes — the guarantee effect closures and
|
|
39
|
+
* `mapArray`'s same-key path rely on.
|
|
32
40
|
* - 'markup': held ref is BOTH boundary comments (start = anchor, end =
|
|
33
41
|
* the matching `<!--/-->` found by a nesting-depth walk (any further
|
|
34
42
|
* `bf:`-prefixed comment along the way opens a nested region). A string
|
|
@@ -102,7 +110,7 @@
|
|
|
102
110
|
*/
|
|
103
111
|
|
|
104
112
|
import { BF_SCOPE, BF_PARENT_OWNED_PREFIX } from '@barefootjs/shared'
|
|
105
|
-
import {
|
|
113
|
+
import { commentsInScope } from './query.ts'
|
|
106
114
|
import { commentScopeRegistry } from './scope.ts'
|
|
107
115
|
|
|
108
116
|
/**
|
|
@@ -136,10 +144,34 @@ export interface SlotSpec {
|
|
|
136
144
|
|
|
137
145
|
export type ClaimPlan = readonly SlotSpec[]
|
|
138
146
|
|
|
139
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* A claimed 'text' slot, represented by ONE field. `ref` is the live Text
|
|
149
|
+
* node once the slot is materialized, and until then — only possible for a
|
|
150
|
+
* MARKED slot SSR rendered empty — the slot's anchor Comment, which doubles
|
|
151
|
+
* as the record of where the Text node must be created. `nodeType`
|
|
152
|
+
* discriminates the two.
|
|
153
|
+
*
|
|
154
|
+
* Claiming a marked text slot therefore never mutates the DOM, which is
|
|
155
|
+
* what the read door depends on: a seed that only compares must not leave a
|
|
156
|
+
* trail of empty Text nodes across every row (`spec/slot-unification.md`
|
|
157
|
+
* §9.3(1)). Markerless slots keep the original eager creation and so always
|
|
158
|
+
* arrive here already materialized — see `claimMarkerlessText` for why
|
|
159
|
+
* deferring them would cost more than it saves.
|
|
160
|
+
*
|
|
161
|
+
* One field rather than a node-plus-site pair because this object is
|
|
162
|
+
* allocated once per slot per row: on a 1k-row list every extra field is
|
|
163
|
+
* paid a thousand times over (measured: +77KB/1k rows for a
|
|
164
|
+
* node+after+parent+index shape). `materializeText` re-reads the insertion
|
|
165
|
+
* point off the anchor at creation time rather than capturing it at claim
|
|
166
|
+
* time, so a sibling slot's write in between cannot stale it.
|
|
167
|
+
*
|
|
168
|
+
* Once materialized the node is held by identity forever, exactly as
|
|
169
|
+
* before: effect closures and `mapArray`'s same-key path rely on that.
|
|
170
|
+
*/
|
|
140
171
|
interface ClaimedTextSlot {
|
|
141
172
|
readonly kind: 'text'
|
|
142
|
-
|
|
173
|
+
/** Text node once materialized; the anchor Comment until then. */
|
|
174
|
+
ref: Text | Comment
|
|
143
175
|
}
|
|
144
176
|
|
|
145
177
|
/**
|
|
@@ -167,6 +199,24 @@ export interface ClaimedSlots {
|
|
|
167
199
|
write(id: string, value: unknown): void
|
|
168
200
|
}
|
|
169
201
|
|
|
202
|
+
/**
|
|
203
|
+
* A claimed plan that can be read as well as written. Separate from
|
|
204
|
+
* {@link ClaimedSlots} because a door is allocated PER ROW: giving every
|
|
205
|
+
* claim a reader costs an extra closure on every row of a list, read or not
|
|
206
|
+
* (measured: ~40KB/1k rows). Only the loops that need read-compare-write
|
|
207
|
+
* seeding (`spec/slot-unification.md` §9.3(1)) ask for this shape. Both
|
|
208
|
+
* shapes sit on the SAME claim (`claimRefs`) — this is a second accessor
|
|
209
|
+
* bundle, never a second way to resolve a position (§2's claim-once rule).
|
|
210
|
+
*/
|
|
211
|
+
export interface ClaimedSlotsRW extends ClaimedSlots {
|
|
212
|
+
/**
|
|
213
|
+
* Current DOM text of a 'text' slot. `''` when the slot rendered empty,
|
|
214
|
+
* `null` when the slot cannot answer (not a 'text' slot, or it failed to
|
|
215
|
+
* claim); `null` MUST be treated by the caller as "differs, write it".
|
|
216
|
+
*/
|
|
217
|
+
read(id: string): string | null
|
|
218
|
+
}
|
|
219
|
+
|
|
170
220
|
/** `lazySlots`'s per-write function — the same shape `ClaimedSlots.write` has. */
|
|
171
221
|
export type SlotWriter = (id: string, value: unknown) => void
|
|
172
222
|
|
|
@@ -324,11 +374,15 @@ function claimMarkerlessText(root: Element, spec: SlotSpec): ClaimedTextSlot | n
|
|
|
324
374
|
}
|
|
325
375
|
const existing = parent.childNodes[idx] as Node | undefined
|
|
326
376
|
if (existing && existing.nodeType === Node.TEXT_NODE) {
|
|
327
|
-
return { kind: 'text',
|
|
377
|
+
return { kind: 'text', ref: existing as Text }
|
|
328
378
|
}
|
|
379
|
+
// Markerless slots keep the original eager creation. They exist only for
|
|
380
|
+
// Step B's `/* @client */` elision, which never applies inside a loop, so
|
|
381
|
+
// no markerless slot is ever a lazy-row seed target — deferring here would
|
|
382
|
+
// buy nothing and would need the parent+index pair the shape above avoids.
|
|
329
383
|
const node = document.createTextNode('')
|
|
330
384
|
parent.insertBefore(node, existing ?? null)
|
|
331
|
-
return { kind: 'text', node }
|
|
385
|
+
return { kind: 'text', ref: node }
|
|
332
386
|
}
|
|
333
387
|
|
|
334
388
|
/** Claim one slot per its kind's contract. `null` on any failure (already warned). */
|
|
@@ -340,7 +394,8 @@ function claimOne(root: Element, spec: SlotSpec): ClaimedSlotRef | null {
|
|
|
340
394
|
if (!anchor) return null
|
|
341
395
|
|
|
342
396
|
if (spec.kind === 'text') {
|
|
343
|
-
|
|
397
|
+
const next = anchor.nextSibling
|
|
398
|
+
return { kind: 'text', ref: next?.nodeType === Node.TEXT_NODE ? (next as Text) : anchor }
|
|
344
399
|
}
|
|
345
400
|
|
|
346
401
|
const end = findMarkupEnd(anchor)
|
|
@@ -353,8 +408,98 @@ function claimOne(root: Element, spec: SlotSpec): ClaimedSlotRef | null {
|
|
|
353
408
|
|
|
354
409
|
// --- writes ---
|
|
355
410
|
|
|
356
|
-
|
|
357
|
-
|
|
411
|
+
/**
|
|
412
|
+
* Create the Text node a claim deliberately did not create, at the position
|
|
413
|
+
* the claim recorded. Reached only from a write against a slot SSR rendered
|
|
414
|
+
* empty — the one case where the DOM genuinely has nothing to write into.
|
|
415
|
+
*/
|
|
416
|
+
function materializeText(slot: ClaimedTextSlot): Text | null {
|
|
417
|
+
const anchor = slot.ref as Comment
|
|
418
|
+
const parent = anchor.parentNode
|
|
419
|
+
if (!parent) return null
|
|
420
|
+
const node = document.createTextNode('')
|
|
421
|
+
parent.insertBefore(node, anchor.nextSibling)
|
|
422
|
+
slot.ref = node
|
|
423
|
+
return node
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Current DOM text of a claimed 'text' slot; `''` while unmaterialized. */
|
|
427
|
+
function readText(slot: ClaimedTextSlot): string {
|
|
428
|
+
return slot.ref.nodeType === Node.TEXT_NODE ? ((slot.ref as Text).nodeValue ?? '') : ''
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function writeText(slot: ClaimedTextSlot, value: unknown): void {
|
|
432
|
+
const node = slot.ref.nodeType === Node.TEXT_NODE ? (slot.ref as Text) : materializeText(slot)
|
|
433
|
+
if (!node) return
|
|
434
|
+
node.nodeValue = String(value ?? '')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Pass a live Node through untouched; coerce anything else with `String`.
|
|
439
|
+
*
|
|
440
|
+
* The 'text' door's counterpart to {@link escapeTextOrNode}. A 'text' slot
|
|
441
|
+
* writes through `nodeValue`, which needs no escaping — routing it through
|
|
442
|
+
* `escapeText` would double-escape — but it DOES need the Node case
|
|
443
|
+
* separated out, because a Text node cannot host an element and
|
|
444
|
+
* `String(node)` destroys it: `[object HTMLDivElement]` in a browser, the
|
|
445
|
+
* serialized markup rendered as visible text under some DOM shims. Wrong
|
|
446
|
+
* either way, and silently so, which is why the split lives here rather
|
|
447
|
+
* than at each call site.
|
|
448
|
+
*
|
|
449
|
+
* A Node reaches a content slot whenever a child-position interpolation
|
|
450
|
+
* calls something that builds one — `props.renderRow(item)` handed an
|
|
451
|
+
* inline-JSX arrow, which the compiler lifts into a component whose call
|
|
452
|
+
* returns a real element. Whether such a call returns a string or a Node is
|
|
453
|
+
* not decidable from the expression's syntax (both are `CallExpression`), so
|
|
454
|
+
* the decision belongs at runtime, on the value.
|
|
455
|
+
*
|
|
456
|
+
* `String(value)`, not `String(value ?? '')`: a non-Node value must coerce
|
|
457
|
+
* exactly as the previous inline `String(...)` emission did. The nullish
|
|
458
|
+
* collapse stays where it already was, in {@link writeText}.
|
|
459
|
+
*/
|
|
460
|
+
export function textOrNode(value: unknown): string | Node {
|
|
461
|
+
if (typeof Node !== 'undefined' && value instanceof Node) return value
|
|
462
|
+
return String(value)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A Node landed on a slot claimed as 'text'. Promote the claim to the
|
|
467
|
+
* 'markup' contract in place — the anchor comment becomes `start`, its
|
|
468
|
+
* matching `<!--/-->` becomes `end` — so this and every later write on the
|
|
469
|
+
* id goes through {@link writeMarkup}, which already splices Nodes by
|
|
470
|
+
* identity.
|
|
471
|
+
*
|
|
472
|
+
* This is a promotion, not a re-claim: the anchor is the SAME comment the
|
|
473
|
+
* original claim resolved (§2's claim-once rule holds — no second position
|
|
474
|
+
* resolution). The Text node the claim adopted or created, if any, sits
|
|
475
|
+
* inside the new range and `clearMarkupRange` removes it on the write.
|
|
476
|
+
*
|
|
477
|
+
* `null` when the slot cannot host a Node — a markerless slot (Step B
|
|
478
|
+
* elision: no anchor to promote from) or a marked slot whose end comment is
|
|
479
|
+
* missing. Both warn: refusing loudly beats stringifying an element into
|
|
480
|
+
* visible `[object HTMLDivElement]`.
|
|
481
|
+
*/
|
|
482
|
+
function promoteTextToMarkup(slot: ClaimedTextSlot, id: string): ClaimedMarkupSlot | null {
|
|
483
|
+
const anchor = slot.ref.nodeType === Node.COMMENT_NODE
|
|
484
|
+
? (slot.ref as Comment)
|
|
485
|
+
// Materialized: `claimOne` adopts/creates the Text node immediately after
|
|
486
|
+
// the anchor, so the anchor is its previous sibling — unless the slot is
|
|
487
|
+
// markerless, where there is no anchor at all and `isSlotComment` says so.
|
|
488
|
+
: isSlotComment(slot.ref.previousSibling, id) ? slot.ref.previousSibling : null
|
|
489
|
+
if (!anchor) {
|
|
490
|
+
console.warn(
|
|
491
|
+
`[barefootjs] slot ${id} was claimed as text and received a Node, but has no anchor marker to promote from; write ignored`,
|
|
492
|
+
)
|
|
493
|
+
return null
|
|
494
|
+
}
|
|
495
|
+
const end = findMarkupEnd(anchor)
|
|
496
|
+
if (!end) {
|
|
497
|
+
console.warn(
|
|
498
|
+
`[barefootjs] slot ${id} was claimed as text and received a Node, but has no end marker; write ignored`,
|
|
499
|
+
)
|
|
500
|
+
return null
|
|
501
|
+
}
|
|
502
|
+
return { kind: 'markup', start: anchor, end, last: undefined }
|
|
358
503
|
}
|
|
359
504
|
|
|
360
505
|
/** Remove every node strictly between `start` and `end` (both survive). */
|
|
@@ -403,19 +548,42 @@ function writeMarkup(ref: ClaimedMarkupSlot, value: unknown): void {
|
|
|
403
548
|
ref.last = text
|
|
404
549
|
}
|
|
405
550
|
|
|
406
|
-
function writeSlot(refs:
|
|
551
|
+
function writeSlot(refs: Map<string, ClaimedSlotRef>, id: string, value: unknown): void {
|
|
407
552
|
const ref = refs.get(id)
|
|
408
553
|
if (!ref) {
|
|
409
554
|
console.warn(`[barefootjs] no claimed slot for id ${id}; write ignored`)
|
|
410
555
|
return
|
|
411
556
|
}
|
|
412
557
|
if (ref.kind === 'text') {
|
|
558
|
+
// A 'text' slot cannot represent an element. Promote once, then fall
|
|
559
|
+
// through to the markup writer — which is also what makes the read door
|
|
560
|
+
// answer `null` for this id afterwards, i.e. "cannot answer, write it",
|
|
561
|
+
// the conservative direction.
|
|
562
|
+
if (typeof Node !== 'undefined' && value instanceof Node) {
|
|
563
|
+
const promoted = promoteTextToMarkup(ref, id)
|
|
564
|
+
if (!promoted) return
|
|
565
|
+
refs.set(id, promoted)
|
|
566
|
+
writeMarkup(promoted, value)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
413
569
|
writeText(ref, value)
|
|
414
570
|
} else {
|
|
415
571
|
writeMarkup(ref, value)
|
|
416
572
|
}
|
|
417
573
|
}
|
|
418
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Read half of the door. `null` means "cannot answer" — the slot is not a
|
|
577
|
+
* 'text' slot, or it never claimed — and every caller must treat that as
|
|
578
|
+
* "differs" and write. Conservative, never unsound. Reads are silent: a
|
|
579
|
+
* slot that failed to claim already warned when it did so.
|
|
580
|
+
*/
|
|
581
|
+
function readSlot(refs: ReadonlyMap<string, ClaimedSlotRef>, id: string): string | null {
|
|
582
|
+
const ref = refs.get(id)
|
|
583
|
+
if (!ref || ref.kind !== 'text') return null
|
|
584
|
+
return readText(ref)
|
|
585
|
+
}
|
|
586
|
+
|
|
419
587
|
// --- public API ---
|
|
420
588
|
|
|
421
589
|
/**
|
|
@@ -425,12 +593,18 @@ function writeSlot(refs: ReadonlyMap<string, ClaimedSlotRef>, id: string, value:
|
|
|
425
593
|
* would naturally occur, per §6) — claim eagerly there instead.
|
|
426
594
|
*/
|
|
427
595
|
export function claimSlots(root: Element, plan: ClaimPlan): ClaimedSlots {
|
|
596
|
+
const refs = claimRefs(root, plan)
|
|
597
|
+
return { write: (id, value) => writeSlot(refs, id, value) }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Resolve every slot in `plan`; slots that fail to claim are simply absent. */
|
|
601
|
+
function claimRefs(root: Element, plan: ClaimPlan): Map<string, ClaimedSlotRef> {
|
|
428
602
|
const refs = new Map<string, ClaimedSlotRef>()
|
|
429
603
|
for (const spec of plan) {
|
|
430
604
|
const ref = claimOne(root, spec)
|
|
431
605
|
if (ref) refs.set(spec.id, ref)
|
|
432
606
|
}
|
|
433
|
-
return
|
|
607
|
+
return refs
|
|
434
608
|
}
|
|
435
609
|
|
|
436
610
|
/**
|
|
@@ -447,3 +621,27 @@ export function lazySlots(root: Element, plan: ClaimPlan): SlotWriter {
|
|
|
447
621
|
claimed.write(id, value)
|
|
448
622
|
}
|
|
449
623
|
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The read-capable twin of `lazySlots`: the same deferred claim, exposed as
|
|
627
|
+
* the full `{ write, read }` door instead of a bare write function.
|
|
628
|
+
*
|
|
629
|
+
* Two entry points rather than one door with a `.read` property because the
|
|
630
|
+
* door is allocated PER ROW: attaching a reader to every writer costs the
|
|
631
|
+
* extra closures on every row in the list, whether or not it ever reads
|
|
632
|
+
* (measured: +84KB/1k rows). Loops that need read-compare-write seeding
|
|
633
|
+
* (`spec/slot-unification.md` §9.3(1)) pay for the reader; every other loop
|
|
634
|
+
* keeps the single-closure writer. There is still exactly ONE claim
|
|
635
|
+
* mechanism underneath — every entry point resolves through `claimRefs`,
|
|
636
|
+
* and the first access of either kind resolves the whole plan while the row
|
|
637
|
+
* is pristine (§2's claim-once rule; reads never open a second resolution
|
|
638
|
+
* path).
|
|
639
|
+
*/
|
|
640
|
+
export function lazyClaimSlots(root: Element, plan: ClaimPlan): ClaimedSlotsRW {
|
|
641
|
+
let refs: Map<string, ClaimedSlotRef> | null = null
|
|
642
|
+
const ensure = (): Map<string, ClaimedSlotRef> => (refs ??= claimRefs(root, plan))
|
|
643
|
+
return {
|
|
644
|
+
write: (id, value) => writeSlot(ensure(), id, value),
|
|
645
|
+
read: (id) => readSlot(ensure(), id),
|
|
646
|
+
}
|
|
647
|
+
}
|
package/src/runtime/component.ts
CHANGED
|
@@ -25,6 +25,41 @@ export function setParentScopeId(id: string | null): void {
|
|
|
25
25
|
_parentScopeId = id
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Where `mapArray` wants a fresh loop row connected before its `init` runs. */
|
|
29
|
+
export type RowMountPoint = { container: Node; anchor: Node | null }
|
|
30
|
+
|
|
31
|
+
// Ambient mount point for a loop row.
|
|
32
|
+
//
|
|
33
|
+
// A loop row created by `renderItem` has no placeholder to replace, so it
|
|
34
|
+
// cannot use `mountAt` and its `init` runs detached (see the known-limitation
|
|
35
|
+
// docstring in `__tests__/runtime/csr-loop-row-init-connected.test.ts`).
|
|
36
|
+
// `mapArray` sets this around the `renderItem` call so the OUTERMOST
|
|
37
|
+
// `createComponent` inside it connects the row at `container`/`anchor` before
|
|
38
|
+
// running `init` — same guarantee `mountAt` gives the child-slot path.
|
|
39
|
+
//
|
|
40
|
+
// Consumed once (take-and-clear) so nested `createComponent` calls made from
|
|
41
|
+
// the row's own init don't re-use the row's mount point.
|
|
42
|
+
//
|
|
43
|
+
// `setRowMountPoint` returns the previous value so a caller can restore it
|
|
44
|
+
// instead of clearing to `null`. That matters because the ambient is a single
|
|
45
|
+
// slot: a row whose own `init` drives a nested `mapArray` would otherwise have
|
|
46
|
+
// the inner list's teardown blank out an outer mount point that had not been
|
|
47
|
+
// consumed yet. Save-and-restore makes the slot behave like a stack without
|
|
48
|
+
// paying for one.
|
|
49
|
+
let _rowMountPoint: RowMountPoint | null = null
|
|
50
|
+
|
|
51
|
+
export function setRowMountPoint(p: RowMountPoint | null): RowMountPoint | null {
|
|
52
|
+
const prev = _rowMountPoint
|
|
53
|
+
_rowMountPoint = p
|
|
54
|
+
return prev
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function takeRowMountPoint(): RowMountPoint | null {
|
|
58
|
+
const p = _rowMountPoint
|
|
59
|
+
_rowMountPoint = null
|
|
60
|
+
return p
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
/**
|
|
29
64
|
* Create a component instance with DOM element and initialized state.
|
|
30
65
|
*
|
|
@@ -38,6 +73,10 @@ export function setParentScopeId(id: string | null): void {
|
|
|
38
73
|
* @param name - Component name (e.g., 'TodoItem')
|
|
39
74
|
* @param props - Props to pass to the component
|
|
40
75
|
* @param key - Optional key for list reconciliation
|
|
76
|
+
* @param slot - Slot-relationship markers stamped as `bf-h` / `bf-m`
|
|
77
|
+
* @param mountAt - Placeholder this component replaces. When given, the
|
|
78
|
+
* element is connected to the document *before* `init` runs — see
|
|
79
|
+
* "Connect before init" below.
|
|
41
80
|
* @returns Created DOM element
|
|
42
81
|
*
|
|
43
82
|
* @example
|
|
@@ -68,15 +107,49 @@ export function createComponent(
|
|
|
68
107
|
props: Record<string, unknown> = {},
|
|
69
108
|
key?: string | number,
|
|
70
109
|
slot?: CreateComponentSlotInfo,
|
|
110
|
+
mountAt?: Element | null,
|
|
111
|
+
): HTMLElement {
|
|
112
|
+
const element = materializeComponent(nameOrDef, props, key, slot, mountAt)
|
|
113
|
+
// `mountAt` is an unconditional obligation: callers used to run
|
|
114
|
+
// `ph.replaceWith(comp)` themselves on every outcome, so every path that
|
|
115
|
+
// did NOT consume the placeholder still owes the replacement — a missing or
|
|
116
|
+
// empty template (either mode), and the root-deferred-placeholder shape,
|
|
117
|
+
// which must stay detached so its self-replacement stays recoverable.
|
|
118
|
+
// `parentNode` (not `isConnected`) is the right "still unconsumed" probe: it
|
|
119
|
+
// survives a `mountAt` that was itself detached, which is the normal case
|
|
120
|
+
// during multi-root loop-body setup.
|
|
121
|
+
if (mountAt && mountAt.parentNode && element !== mountAt) {
|
|
122
|
+
mountAt.replaceWith(element)
|
|
123
|
+
}
|
|
124
|
+
return element
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the element, connect it at `mountAt` when there is one, and run
|
|
129
|
+
* `init` — in that order, which is the whole point (see step 7b).
|
|
130
|
+
*
|
|
131
|
+
* Named "materialize" rather than anything with "unmounted" in it because it
|
|
132
|
+
* DOES mount: the connect has to happen inside, before `init`, and only the
|
|
133
|
+
* paths that cannot consume the placeholder leave that to `createComponent`.
|
|
134
|
+
*/
|
|
135
|
+
function materializeComponent(
|
|
136
|
+
nameOrDef: string | ComponentDef,
|
|
137
|
+
props: Record<string, unknown> = {},
|
|
138
|
+
key?: string | number,
|
|
139
|
+
slot?: CreateComponentSlotInfo,
|
|
140
|
+
mountAt?: Element | null,
|
|
71
141
|
): HTMLElement {
|
|
72
142
|
// A bare callable shim invoked from user code (e.g. an object-literal
|
|
73
143
|
// value `LOGOS[id]()` whose arrow the compiler hoisted into a component)
|
|
74
144
|
// reaches us with no props (#1663). Normalize to an empty object so the
|
|
75
145
|
// descriptor probes below don't throw on `undefined`.
|
|
76
146
|
if (props == null) props = {}
|
|
147
|
+
// Take the row mount point BEFORE any template eval / init can run, so the
|
|
148
|
+
// outermost call for the row is the only one that can consume it.
|
|
149
|
+
const rowMount = mountAt ? null : takeRowMountPoint()
|
|
77
150
|
// ComponentDef mode: use def directly instead of registry lookup
|
|
78
151
|
if (typeof nameOrDef !== 'string') {
|
|
79
|
-
return createComponentFromDef(nameOrDef, props, key)
|
|
152
|
+
return createComponentFromDef(nameOrDef, props, key, mountAt, rowMount)
|
|
80
153
|
}
|
|
81
154
|
|
|
82
155
|
const name = nameOrDef
|
|
@@ -173,6 +246,35 @@ export function createComponent(
|
|
|
173
246
|
element.setAttribute(BF_KEY, String(key))
|
|
174
247
|
}
|
|
175
248
|
|
|
249
|
+
// 7b. Connect before init.
|
|
250
|
+
//
|
|
251
|
+
// `initFn` resolves context by DOM position (`useContext` walks
|
|
252
|
+
// `parentElement` from the current scope) and may measure layout. Both
|
|
253
|
+
// need this element to be in the document, so when the caller told us
|
|
254
|
+
// which placeholder we replace, do the replacement NOW rather than
|
|
255
|
+
// after init. Running init detached made `useContext` fall through to
|
|
256
|
+
// the global, last-writer-wins context store, so a child materialised
|
|
257
|
+
// after a sibling provider had run picked up the wrong provider's
|
|
258
|
+
// value. This aligns the CSR path with the SSR one, where the
|
|
259
|
+
// doc-order walker only ever inits elements already in the document
|
|
260
|
+
// (`hydrate.ts`).
|
|
261
|
+
//
|
|
262
|
+
// A root-level deferred placeholder is excluded: its init replaces
|
|
263
|
+
// `element` itself, which the block below recovers via a throwaway
|
|
264
|
+
// wrapper. Connecting first would make that replacement happen in the
|
|
265
|
+
// live DOM with no handle on the result, so this shape keeps the
|
|
266
|
+
// detached behaviour.
|
|
267
|
+
const rootIsDeferredPlaceholder = element.hasAttribute(BF_PLACEHOLDER)
|
|
268
|
+
if (mountAt && !rootIsDeferredPlaceholder) {
|
|
269
|
+
mountAt.replaceWith(element)
|
|
270
|
+
} else if (rowMount && !rootIsDeferredPlaceholder) {
|
|
271
|
+
// Loop row: no placeholder exists, so connect at the position `mapArray`
|
|
272
|
+
// handed down. The reorder step may move the row afterwards; any position
|
|
273
|
+
// inside the container yields the same ancestor chain, which is all
|
|
274
|
+
// `useContext`'s parentElement walk needs.
|
|
275
|
+
rowMount.container.insertBefore(element, rowMount.anchor)
|
|
276
|
+
}
|
|
277
|
+
|
|
176
278
|
// 8. Set currentScope so provideContext/useContext are element-scoped.
|
|
177
279
|
// This allows context providers in initFn to store context on this element.
|
|
178
280
|
const prevScope = setCurrentScope(element)
|
|
@@ -184,7 +286,6 @@ export function createComponent(
|
|
|
184
286
|
// `replaceWith` — but a detached root node can't replace itself in
|
|
185
287
|
// place. Park it in a throwaway wrapper so the replacement lands
|
|
186
288
|
// somewhere we can recover, then return the materialised child.
|
|
187
|
-
const rootIsDeferredPlaceholder = element.hasAttribute(BF_PLACEHOLDER)
|
|
188
289
|
let placeholderWrapper: HTMLElement | null = null
|
|
189
290
|
if (rootIsDeferredPlaceholder) {
|
|
190
291
|
placeholderWrapper = parseHTML('<div></div>').firstChild as HTMLElement
|
|
@@ -532,7 +633,9 @@ function insertGetterChildren(element: HTMLElement, children: unknown): void {
|
|
|
532
633
|
function createComponentFromDef(
|
|
533
634
|
def: ComponentDef,
|
|
534
635
|
props: Record<string, unknown>,
|
|
535
|
-
key?: string | number
|
|
636
|
+
key?: string | number,
|
|
637
|
+
mountAt?: Element | null,
|
|
638
|
+
rowMount?: { container: Node; anchor: Node | null } | null,
|
|
536
639
|
): HTMLElement {
|
|
537
640
|
if (!def.template) {
|
|
538
641
|
throw new Error('[BarefootJS] createComponent with ComponentDef requires a template function')
|
|
@@ -560,6 +663,16 @@ function createComponentFromDef(
|
|
|
560
663
|
element.setAttribute(BF_KEY, String(key))
|
|
561
664
|
}
|
|
562
665
|
|
|
666
|
+
// Connect before init, for the same reason the registry path does (see
|
|
667
|
+
// `materializeComponent` step 7b): `def.init` may resolve context by DOM
|
|
668
|
+
// position or measure layout, and neither works detached. Keeps `mountAt`
|
|
669
|
+
// one contract across both modes instead of a registry-only guarantee.
|
|
670
|
+
if (mountAt) {
|
|
671
|
+
mountAt.replaceWith(element)
|
|
672
|
+
} else if (rowMount) {
|
|
673
|
+
rowMount.container.insertBefore(element, rowMount.anchor)
|
|
674
|
+
}
|
|
675
|
+
|
|
563
676
|
// Initialize
|
|
564
677
|
def.init(element, props)
|
|
565
678
|
|
package/src/runtime/index.ts
CHANGED
|
@@ -82,12 +82,16 @@ export {
|
|
|
82
82
|
export { getLoopChildren, getLoopNodes } from './loop-markers.ts'
|
|
83
83
|
export { qsaItem, upsertChildItem } from './qsa-item.ts'
|
|
84
84
|
export { mapArray, mapArrayAnchored } from './map-array.ts'
|
|
85
|
+
// Lazy row graph (slot unification §9, L2) — keyed list rendering with no
|
|
86
|
+
// per-row reactive resources; compiler targets it for eligible plain loops
|
|
87
|
+
// (L3). See ./map-array-lazy.ts for the pinned row-plan contract.
|
|
88
|
+
export { mapArrayLazy, type LazyRowEntry, type LazyRowPlan } from './map-array-lazy.ts'
|
|
85
89
|
export { patchLeaf } from './patch-leaf.ts'
|
|
86
90
|
|
|
87
91
|
// Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
|
|
88
92
|
// — the ONE content-slot update mechanism, wired up by the compiler in A3.
|
|
89
93
|
// Supersedes (deleted) `patchSlotRange` and `updateClientMarker`.
|
|
90
|
-
export { claimSlots, lazySlots, type SlotSpec, type ClaimPlan, type ClaimedSlots, type SlotWriter } from './claim-slots.ts'
|
|
94
|
+
export { claimSlots, lazySlots, lazyClaimSlots, textOrNode, type SlotSpec, type ClaimPlan, type ClaimedSlots, type ClaimedSlotsRW, type SlotWriter } from './claim-slots.ts'
|
|
91
95
|
|
|
92
96
|
// Template registry
|
|
93
97
|
export { registerTemplate, getTemplate, hasTemplate, type TemplateFn } from './template.ts'
|