@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.
@@ -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, CREATED if SSR emitted an empty value (`textNodeAfterComment`,
29
- * exactly `$t`'s `tAfter` behavior). Writes are a `nodeValue` assignment
30
- * the Text node's identity never changes, which is the guarantee
31
- * effect closures and `mapArray`'s same-key path rely on.
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 { textNodeAfterComment, commentsInScope } from './query.ts'
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
- /** A claimed 'text' slot: the live Text node, held by identity forever. */
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
- readonly node: Text
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', node: existing as 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
- return { kind: 'text', node: textNodeAfterComment(anchor) }
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
- function writeText(ref: ClaimedTextSlot, value: unknown): void {
357
- ref.node.nodeValue = String(value ?? '')
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: ReadonlyMap<string, ClaimedSlotRef>, id: string, value: unknown): void {
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 { write: (id, value) => writeSlot(refs, id, value) }
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
+ }
@@ -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
 
@@ -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'