@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.
@@ -0,0 +1,470 @@
1
+ /**
2
+ * BarefootJS - Lazy Row Graph List Rendering (slot unification §9, L2)
3
+ *
4
+ * `mapArrayLazy` renders a keyed reactive array WITHOUT any per-row reactive
5
+ * resources: no `createRoot`, no per-item signal, no per-row effect, no
6
+ * hydration-time query/claim/DOM-write per row. See
7
+ * `spec/slot-unification.md` §9 for the design and the measurement spike
8
+ * that motivated it (branch `claude/lazy-effect-spike`).
9
+ *
10
+ * A plain loop row has exactly two update paths, both already known without
11
+ * per-row reactivity (§9.1):
12
+ *
13
+ * 1. **Item-driven changes** — the keyed reconciler detects them itself
14
+ * (`!Object.is(oldItem, newItem)` per key) and calls the row plan's
15
+ * `applyItem` directly. The row's DOM refs are claimed lazily on that
16
+ * row's FIRST item-driven write (a scan inside that one row, cached on
17
+ * `entry.refs`); a row that never updates never pays.
18
+ * 2. **Outer-signal reads** — applied by ONE loop-level `createEffect`
19
+ * (`applyOuter`) iterating all entries with per-entry dedup, created
20
+ * only when the loop has such bindings.
21
+ *
22
+ * ## Row-plan contract (PINNED — L3's compiler emission targets exactly this)
23
+ *
24
+ * The compiler emits a {@link LazyRowPlan} per eligible loop
25
+ * (§9.4 eligibility: plain single-root keyed rows whose data source is
26
+ * hydration-consistent; everything else keeps the eager `mapArray`
27
+ * emission). Obligations, split by side:
28
+ *
29
+ * **Runtime (this module) guarantees:**
30
+ * - Hydration first run adopts SSR rows with ZERO per-row DOM mutations:
31
+ * `entry.key` is READ from the SSR-rendered `data-key` attribute (never
32
+ * written on adopted rows; `getKey(items[i], i)` is the fallback when the
33
+ * attribute is absent), `entry.item = items[i]` positionally (sound by
34
+ * the §9.3(2) compile-time eligibility gate), `refs`/`last` start `null`.
35
+ * - `plan.createRow` / `plan.applyItem` are invoked inside the reconciler
36
+ * effect but wrapped in `untrack()`, so outer-signal reads during row
37
+ * creation or item application never subscribe the reconciler — it re-runs
38
+ * only when `accessor()`'s dependencies change. (`applyItem` is untracked
39
+ * for the same reason `createRow` is: mixed item+outer bindings read outer
40
+ * signals non-reactively there; the `applyOuter` effect owns the reactive
41
+ * side.)
42
+ * - The runtime assigns `createRow`'s returned element to `entry.primaryEl`
43
+ * and stamps `data-key` on CSR-created rows if `createRow` didn't (same
44
+ * semantics as `mapArray`'s create path).
45
+ * - When `plan.applyOuter` exists, ONE loop-level effect is created (after
46
+ * the reconciler effect, so its first run happens after adoption) whose
47
+ * body calls `applyOuter(entryList, seed)` with `seed === true` exactly
48
+ * once, on the very first run. `entryList` is a closure variable holding
49
+ * the entries in current order, REBUILT (reassigned) by the reconciler
50
+ * after every reconcile — chosen over a live/mutable view so a run of the
51
+ * effect can never observe a half-reconciled list, and read
52
+ * non-reactively so the effect re-runs ONLY on the outer signals
53
+ * `applyOuter` itself reads, never because the list was reconciled.
54
+ * - **Re-subscribe seam**: the previous bullet's "reconciles never re-run
55
+ * this effect" contract holds only when every outer read subscribes
56
+ * independently of the entries. That is true for a plain signal/memo
57
+ * getter and FALSE for a per-key subscription such as `createSelector`,
58
+ * whose selector subscribes the caller only to the specific keys it was
59
+ * called with — so a reconcile can leave the effect subscribed to keys
60
+ * that no longer matter and NOT subscribed to keys that now do. Every
61
+ * loop with an `applyOuter` therefore re-runs it after any reconcile that
62
+ * created a row or changed an item (removals strand nothing). Applied
63
+ * unconditionally rather than gated on a compiler judgement about which
64
+ * reads are per-key: see the seam's comment inside `mapArrayLazy` for why
65
+ * (a misclassification must be harmless, not silently wrong) and for the
66
+ * three stranding sequences it prevents, each reproduced before it existed.
67
+ *
68
+ * **Plan (compiler-emitted) obligations:**
69
+ * - `createRow` MUST write ALL bindings — item-driven AND outer-involving —
70
+ * with current values (it is CSR creation; it computes everything anyway)
71
+ * and MUST seed `entry.refs`/`entry.last` from known clone paths (no
72
+ * scan). Freshly-created rows are therefore consistent immediately; the
73
+ * `applyOuter` effect's per-entry dedup (seeded via `entry.last`) keeps
74
+ * them consistent on later outer-signal changes.
75
+ * - `applyOuter`'s FIRST run (`seed === true`) must READ current DOM state
76
+ * (`getAttribute` / `nodeValue`) to initialize each entry's dedup value
77
+ * and write only where the computed value differs — read-compare-write
78
+ * seeding (§9.3(1)), sound even when outer state is client-only and
79
+ * diverges from SSR. No trust-first-run regression (§6).
80
+ * - `applyItem` claims refs lazily (scan within `entry.primaryEl`) when
81
+ * `entry.refs` is null, and writes through per-binding dedup held on
82
+ * `entry.last` / `entry.refs`.
83
+ *
84
+ * ## Reconciliation
85
+ *
86
+ * The keyed diff, duplicate-key once-per-reconcile warning, clear-all fast
87
+ * path, and LIS minimal-move reorder mirror `mapArray`'s exactly — minus
88
+ * every per-row reactive resource. Removal is plain DOM detach: entries
89
+ * hold no reactive resources (CSR rows created by `plan.createRow` hold
90
+ * none either), so there is nothing to dispose.
91
+ *
92
+ * Single-root rows only (v1): the §9.4 eligibility gate guarantees the
93
+ * compiler never targets this entry point for multi-root (Fragment) rows,
94
+ * so there is no `startMarker`/`extras`/`bf-loop-i` bookkeeping here.
95
+ *
96
+ * Rows are NOT added to `hydratedScopes`: that mark exists for element
97
+ * scopes the hydration walker must skip, and lazy-eligible rows are plain
98
+ * markup (no nested component/host scopes — the eligibility gate excludes
99
+ * them), so the mark would only spend per-row memory this design exists to
100
+ * eliminate.
101
+ *
102
+ * Shared helpers: `findLoopMarkers` and `longestIncreasingSubsequenceIndices`
103
+ * are imported from `./map-array.ts` (now exported for internal reuse)
104
+ * rather than duplicated or extracted into a third module — they are pure,
105
+ * behavior-identical for both reconcilers, and importing keeps exactly one
106
+ * copy without churning `map-array.ts`'s structure. The loop-shaped logic
107
+ * around them (partition, diff, clear fast path) is intentionally
108
+ * re-written here rather than shared: it differs in what it carries per row
109
+ * (plain entries vs reactive scopes) and forcing one parameterized body
110
+ * would obscure both.
111
+ *
112
+ * `bfId` is forwarded to the reconciler effect only (same attribution point
113
+ * as `mapArray`); profile mode never emits lazy loops (§9.4), so the outer
114
+ * effect carries no id.
115
+ */
116
+
117
+ import { createEffect, createSignal, untrack } from '@barefootjs/client/reactive'
118
+ import { BF_KEY } from '@barefootjs/shared'
119
+ import { findLoopMarkers, longestIncreasingSubsequenceIndices } from './map-array.ts'
120
+
121
+ /**
122
+ * One row of a lazy loop: plain data, no reactive resources.
123
+ * Built at adoption (hydration) or by the reconciler via `plan.createRow`.
124
+ */
125
+ export interface LazyRowEntry<T> {
126
+ key: string
127
+ primaryEl: HTMLElement
128
+ item: T
129
+ /** plan-owned: claimed DOM refs, null until the row's first item-driven write */
130
+ refs: unknown | null
131
+ /** plan-owned: per-binding last-value dedup state */
132
+ last: unknown | null
133
+ }
134
+
135
+ /**
136
+ * The compiler-emitted row plan for a lazy-eligible loop.
137
+ * See the module docstring for the pinned contract and each side's
138
+ * obligations.
139
+ */
140
+ export interface LazyRowPlan<T> {
141
+ /** CSR create: clone/build a fully-written row element for item; record refs
142
+ * and dedup state directly on the entry (no scan). Returns the element. */
143
+ createRow(entry: LazyRowEntry<T>, index: number): HTMLElement
144
+ /** Item-driven (and mixed) bindings: called by the reconciler AFTER
145
+ * entry.item has been updated to the new item; prevItem is the old value.
146
+ * Claims refs lazily (scan within entry.primaryEl) when entry.refs is null.
147
+ * Writes through per-binding dedup held on entry.last / entry.refs. */
148
+ applyItem(entry: LazyRowEntry<T>, prevItem: T): void
149
+ /** Outer-involving bindings (present only when the loop has bindings that
150
+ * read signals from outside the row). Runtime wraps this in ONE
151
+ * createEffect for the whole loop. Reads its outer signals inside the
152
+ * callback (so the effect subscribes), then applies those bindings to
153
+ * every entry with per-entry dedup. `seed` is true on the effect's FIRST
154
+ * run only: the binding must READ current DOM state (getAttribute /
155
+ * nodeValue) to initialize its dedup value and write only where the
156
+ * computed value differs (read-compare-write, spec §9.3(1)). */
157
+ applyOuter?(entries: ReadonlyArray<LazyRowEntry<T>>, seed: boolean): void
158
+ }
159
+
160
+ /**
161
+ * Lazy-row-graph keyed list rendering (spec/slot-unification.md §9).
162
+ *
163
+ * @param accessor - Function returning the reactive array (signal/memo read)
164
+ * @param container - DOM container element
165
+ * @param getKey - Key extractor (null = use index). Receives plain item value.
166
+ * @param plan - Compiler-emitted row plan (see {@link LazyRowPlan})
167
+ * @param markerId - Scoped loop marker id (`<!--bf-loop:<id>-->`), see #1087
168
+ * @param bfId - Profiler attribution id for the reconciler effect
169
+ */
170
+ export function mapArrayLazy<T>(
171
+ accessor: () => T[],
172
+ container: HTMLElement | null,
173
+ getKey: ((item: T, index: number) => string) | null,
174
+ plan: LazyRowPlan<T>,
175
+ markerId?: string,
176
+ bfId?: string,
177
+ ): void {
178
+ if (!container) return
179
+
180
+ const entries = new Map<string, LazyRowEntry<T>>()
181
+ /**
182
+ * Entries in current item order — the closure variable the reconciler
183
+ * reassigns after every reconcile and the `applyOuter` effect reads
184
+ * non-reactively (so reconciles never re-run that effect).
185
+ */
186
+ let entryList: LazyRowEntry<T>[] = []
187
+ let hydrated = false
188
+
189
+ /**
190
+ * Re-subscribe seam. `applyOuter` subscribes to whatever its body reads,
191
+ * and for a NON-primable outer read that set depends on the entries it
192
+ * iterated — so a reconcile can strand it. Three sequences, all
193
+ * reproduced against `createSelector` before this existed:
194
+ *
195
+ * 1. the entry list is EMPTY on the effect's first run, so the per-entry
196
+ * reads never execute, nothing is subscribed, and the loop is dead
197
+ * forever;
198
+ * 2. a row is CREATED (under `untrack`, so its key is never registered)
199
+ * and then becomes the selected one — only that key flips, nobody
200
+ * listens, and the row stays stale. Note the list is never empty here,
201
+ * which is why an empty -> non-empty trigger is not enough;
202
+ * 3. an ITEM changes the value a binding keys on (loop key derived from a
203
+ * different field), stranding the old key the same way.
204
+ *
205
+ * So the trigger is "the reconcile created a row or changed an item", not
206
+ * "the list became non-empty". Removals strand nothing — the surviving
207
+ * entries keep their subscriptions — so they do not bump.
208
+ *
209
+ * Unconditional for every loop that has an `applyOuter`, deliberately.
210
+ * Gating it on "the compiler believes this loop's outer reads are not
211
+ * primable" would make a MISCLASSIFICATION silently wrong — a read the
212
+ * compiler thought was a plain signal but that subscribes per key would
213
+ * strand exactly as above, with no test able to see it. Unconditional
214
+ * makes the same mistake harmless. The price is one extra dedup-guarded
215
+ * `applyOuter` pass per row-creating or item-changing reconcile, measured
216
+ * as below this repo's benchmark floor: forcing it on for a loop that does
217
+ * not need it moved post-hydration heap 1815.5KB -> 1809.1KB and hydration
218
+ * 44.15ms -> 24.55ms, i.e. it measured FASTER, which is noise, not signal.
219
+ */
220
+ const needsResubscribe = plan.applyOuter !== undefined
221
+ const [generation, bumpGeneration] = needsResubscribe ? createSignal(0) : [null, null]
222
+ let stranded = false
223
+ const markStranded = (): void => {
224
+ if (needsResubscribe) stranded = true
225
+ }
226
+ const flushStranded = (): void => {
227
+ if (stranded && bumpGeneration) {
228
+ stranded = false
229
+ bumpGeneration((n) => n + 1)
230
+ }
231
+ }
232
+
233
+ // Loop boundary markers are structural — never removed or re-inserted by
234
+ // this module — so cache them across effect runs, same as `mapArray`.
235
+ let cachedStart: Comment | null = null
236
+ let cachedEnd: Comment | null = null
237
+ const resolveMarkers = (): { start: Comment | null; end: Comment | null } => {
238
+ if (cachedStart && cachedEnd && cachedStart.isConnected && cachedEnd.isConnected) {
239
+ return { start: cachedStart, end: cachedEnd }
240
+ }
241
+ const found = findLoopMarkers(container, markerId)
242
+ cachedStart = found.start
243
+ cachedEnd = found.end
244
+ return found
245
+ }
246
+
247
+ /**
248
+ * CSR row creation. `plan.createRow` runs inside the reconciler effect,
249
+ * so it is wrapped in `untrack()`: it writes outer-involving bindings
250
+ * with current values (contract), and those signal reads must not
251
+ * subscribe the reconciler. The returned element is assigned to
252
+ * `entry.primaryEl`; `data-key` is stamped if `createRow` didn't
253
+ * (mirrors `mapArray`'s create path).
254
+ */
255
+ const createEntry = (item: T, index: number, key: string): LazyRowEntry<T> => {
256
+ const entry: LazyRowEntry<T> = {
257
+ key,
258
+ // Assigned from createRow's return value below; createRow builds the
259
+ // element and must not read primaryEl.
260
+ primaryEl: undefined as unknown as HTMLElement,
261
+ item,
262
+ refs: null,
263
+ last: null,
264
+ }
265
+ entry.primaryEl = untrack(() => plan.createRow(entry, index))
266
+ if (!entry.primaryEl.dataset.key) entry.primaryEl.setAttribute(BF_KEY, key)
267
+ markStranded()
268
+ return entry
269
+ }
270
+
271
+ createEffect(() => {
272
+ const items = accessor()
273
+ if (!items) return
274
+
275
+ const { start: startMarker, end: endMarker } = resolveMarkers()
276
+ const anchor: Node | null = endMarker ?? null
277
+
278
+ // --- First run: adopt SSR-rendered rows (zero per-row DOM mutations) ---
279
+ if (!hydrated) {
280
+ hydrated = true
281
+ // Single-root rows: each ELEMENT_NODE child in the loop range is one row.
282
+ const doms: HTMLElement[] = []
283
+ for (
284
+ let node: Node | null = startMarker ? startMarker.nextSibling : container.firstChild;
285
+ node && node !== anchor;
286
+ node = node.nextSibling
287
+ ) {
288
+ if (node.nodeType === Node.ELEMENT_NODE) doms.push(node as HTMLElement)
289
+ }
290
+ if (doms.length > 0 && entries.size === 0) {
291
+ const list: LazyRowEntry<T>[] = []
292
+ const shared = Math.min(doms.length, items.length)
293
+ for (let i = 0; i < shared; i++) {
294
+ const el = doms[i]
295
+ // READ the SSR-rendered key (never write it on adopted rows);
296
+ // positional item pairing is sound by the §9.3(2) eligibility gate.
297
+ const ssrKey = el.getAttribute(BF_KEY)
298
+ const key = ssrKey !== null ? ssrKey : getKey ? getKey(items[i], i) : String(i)
299
+ const entry: LazyRowEntry<T> = { key, primaryEl: el, item: items[i], refs: null, last: null }
300
+ entries.set(key, entry)
301
+ list.push(entry)
302
+ }
303
+ // SSR rendered fewer rows than the current array — create the rest (CSR).
304
+ for (let i = doms.length; i < items.length; i++) {
305
+ const item = items[i]
306
+ const key = getKey ? getKey(item, i) : String(i)
307
+ const entry = createEntry(item, i, key)
308
+ entries.set(key, entry)
309
+ list.push(entry)
310
+ container.insertBefore(entry.primaryEl, anchor)
311
+ }
312
+ // SSR rendered more rows than the current array — drop the orphans.
313
+ for (let i = items.length; i < doms.length; i++) doms[i].remove()
314
+ entryList = list
315
+ flushStranded()
316
+ return // Adoption complete — later accessor changes reconcile below.
317
+ }
318
+ // No SSR rows (CSR mount): fall through to the keyed path.
319
+ }
320
+
321
+ // --- Fast path: clearing the whole list ---
322
+ // Mirrors `mapArray`: one ranged delete between markers, or a bulk
323
+ // `textContent = ''` when the list owns the container's children
324
+ // outright (verified by a node count so foreign siblings survive).
325
+ if (items.length === 0) {
326
+ if (entries.size > 0) {
327
+ if (startMarker && endMarker) {
328
+ const range = document.createRange()
329
+ range.setStartAfter(startMarker)
330
+ range.setEndBefore(endMarker)
331
+ range.deleteContents()
332
+ } else {
333
+ let actualNodeCount = 0
334
+ for (let node = container.firstChild; node; node = node.nextSibling) actualNodeCount++
335
+ if (actualNodeCount === entries.size) {
336
+ container.textContent = ''
337
+ } else {
338
+ for (const entry of entries.values()) entry.primaryEl.remove()
339
+ }
340
+ }
341
+ entries.clear()
342
+ entryList = []
343
+ }
344
+ // No flush: clearing strands nothing (there is nothing left to keep
345
+ // subscribed), and `markStranded` is never set by a removal.
346
+ return
347
+ }
348
+
349
+ // --- Key-based diff ---
350
+ const newKeys = new Set<string>()
351
+ // Distinct from `newKeys`: tracks which keys have ALREADY emitted a
352
+ // duplicate warning in this reconcile, so a 1000-item list where every
353
+ // item shares one key emits ONE warning, not 999 (same as `mapArray`).
354
+ const warnedKeys = new Set<string>()
355
+ const desiredOrder: LazyRowEntry<T>[] = []
356
+
357
+ for (let i = 0; i < items.length; i++) {
358
+ const item = items[i]
359
+ const key = getKey ? getKey(item, i) : String(i)
360
+ if (newKeys.has(key) && !warnedKeys.has(key)) {
361
+ warnedKeys.add(key)
362
+ console.warn(
363
+ `[BarefootJS] mapArrayLazy: duplicate key "${key}" — items with this key collapse to a single DOM row, ` +
364
+ `so only the last one renders. Use a per-item identifier (e.g. \`key={item.id}\`) for correct reconciliation.`,
365
+ )
366
+ }
367
+ newKeys.add(key)
368
+
369
+ const existing = entries.get(key)
370
+ if (existing) {
371
+ // Same key: item-driven update is a DIRECT call — no signal, no
372
+ // setItem. `applyItem` runs after `entry.item` is updated, receives
373
+ // the previous item, and is untracked (mixed bindings may read
374
+ // outer signals; the applyOuter effect owns the reactive side).
375
+ if (!Object.is(existing.item, item)) {
376
+ const prevItem = existing.item
377
+ existing.item = item
378
+ untrack(() => plan.applyItem(existing, prevItem))
379
+ markStranded()
380
+ }
381
+ desiredOrder.push(existing)
382
+ } else {
383
+ const entry = createEntry(item, i, key)
384
+ entries.set(key, entry)
385
+ desiredOrder.push(entry)
386
+ }
387
+ }
388
+
389
+ // Remove rows no longer in the array. Plain DOM detach — entries hold
390
+ // no reactive resources (adopted and CSR-created alike), nothing to
391
+ // dispose.
392
+ for (const [key, entry] of entries) {
393
+ if (!newKeys.has(key)) {
394
+ if (entry.primaryEl.parentNode) entry.primaryEl.remove()
395
+ entries.delete(key)
396
+ }
397
+ }
398
+
399
+ // --- Reconcile DOM order: minimal-move, LIS-based (same as mapArray) ---
400
+ // Rows kept stationary by the LIS are provably never detached; every
401
+ // other row (moves + brand-new rows) is grouped into contiguous runs
402
+ // inserted with ONE insertBefore per run.
403
+ const primaryElToDesiredIndex = new Map<HTMLElement, number>()
404
+ for (let i = 0; i < desiredOrder.length; i++) {
405
+ primaryElToDesiredIndex.set(desiredOrder[i].primaryEl, i)
406
+ }
407
+
408
+ const domOrderIndices: number[] = []
409
+ for (
410
+ let node: Node | null = startMarker ? startMarker.nextSibling : container.firstChild;
411
+ node && node !== anchor;
412
+ node = node.nextSibling
413
+ ) {
414
+ if (node.nodeType !== Node.ELEMENT_NODE) continue
415
+ const idx = primaryElToDesiredIndex.get(node as HTMLElement)
416
+ if (idx !== undefined) domOrderIndices.push(idx)
417
+ }
418
+
419
+ const stationary = new Array<boolean>(desiredOrder.length).fill(false)
420
+ for (const pos of longestIncreasingSubsequenceIndices(domOrderIndices)) {
421
+ stationary[domOrderIndices[pos]] = true
422
+ }
423
+
424
+ let i = 0
425
+ while (i < desiredOrder.length) {
426
+ if (stationary[i]) { i++; continue }
427
+ let j = i
428
+ while (j < desiredOrder.length && !stationary[j]) j++
429
+ const before = j < desiredOrder.length ? desiredOrder[j].primaryEl : anchor
430
+ if (j - i === 1) {
431
+ container.insertBefore(desiredOrder[i].primaryEl, before)
432
+ } else {
433
+ const runFragment = document.createDocumentFragment()
434
+ for (let k = i; k < j; k++) runFragment.appendChild(desiredOrder[k].primaryEl)
435
+ container.insertBefore(runFragment, before)
436
+ }
437
+ i = j
438
+ }
439
+
440
+ entryList = desiredOrder
441
+ // ONE bump per reconcile, after `entryList` is current so the re-run
442
+ // iterates the new entries.
443
+ flushStranded()
444
+ }, bfId)
445
+
446
+ // --- ONE loop-level effect for outer-involving bindings ---
447
+ // Created AFTER the reconciler effect (createEffect runs its body
448
+ // synchronously, so adoption has already happened when this first runs).
449
+ // `seed` is true exactly once, on the very first run — set false before
450
+ // calling so a throwing applyOuter can never seed twice. The effect reads
451
+ // `entryList` from the closure (non-reactive), so it re-runs only when
452
+ // the outer signals `applyOuter` reads inside its body change — never
453
+ // because the list was reconciled.
454
+ if (plan.applyOuter) {
455
+ const applyOuter = plan.applyOuter.bind(plan)
456
+ let seed = true
457
+ createEffect(() => {
458
+ // Subscribe to the seam so a stranding reconcile re-runs this effect
459
+ // and its per-entry reads re-subscribe against the CURRENT entries.
460
+ // Read unconditionally (not inside the `needsResubscribe` branch) is
461
+ // impossible — `generation` only exists for loops that opted in — so
462
+ // loops that did not opt in keep a subscription set built purely from
463
+ // what `applyOuter` itself reads.
464
+ if (generation) generation()
465
+ const isSeed = seed
466
+ seed = false
467
+ applyOuter(entryList, isSeed)
468
+ })
469
+ }
470
+ }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { createSignal, createEffect, createRoot } from '@barefootjs/client/reactive'
22
22
  import { hydratedScopes } from './hydration-state.ts'
23
+ import { setRowMountPoint } from './component.ts'
23
24
  import {
24
25
  BF_KEY,
25
26
  BF_LOOP_START,
@@ -62,8 +63,11 @@ type ItemScope<T> = {
62
63
  * When omitted (e.g. hand-written tests that drop in unscoped markers),
63
64
  * falls back to the first start / first end found, matching either the
64
65
  * scoped or legacy unscoped form.
66
+ *
67
+ * Exported for `./map-array-lazy.ts` (internal reuse only — not re-exported
68
+ * from the runtime index).
65
69
  */
66
- function findLoopMarkers(
70
+ export function findLoopMarkers(
67
71
  container: HTMLElement,
68
72
  markerId?: string,
69
73
  ): { start: Comment | null; end: Comment | null } {
@@ -171,8 +175,11 @@ function insertScope<T>(scope: ItemScope<T>, target: Node, anchor: Node | null):
171
175
  * (plus any brand-new one) needs to move. This is the same strategy
172
176
  * keyed-diff reconcilers in the udomdiff/Solid family use to turn an
173
177
  * arbitrary reorder into a minimal set of DOM moves.
178
+ *
179
+ * Exported for `./map-array-lazy.ts` (internal reuse only — not re-exported
180
+ * from the runtime index).
174
181
  */
175
- function longestIncreasingSubsequenceIndices(arr: number[]): number[] {
182
+ export function longestIncreasingSubsequenceIndices(arr: number[]): number[] {
176
183
  const n = arr.length
177
184
  if (n === 0) return []
178
185
  // tails[k] = index into `arr` of the smallest possible tail value for an
@@ -227,6 +234,7 @@ function createItemScope<T>(
227
234
  existingPrimary?: HTMLElement,
228
235
  existingExtras?: HTMLElement[],
229
236
  existingStart?: Comment | null,
237
+ rowMount?: { container: Node; anchor: Node | null } | null,
230
238
  ): ItemScope<T> {
231
239
  let primaryEl!: HTMLElement
232
240
  let dispose!: () => void
@@ -238,7 +246,22 @@ function createItemScope<T>(
238
246
  dispose = d
239
247
  const [itemAccessor, itemSetter] = createSignal(item)
240
248
  setItem = itemSetter
241
- primaryEl = renderItem(itemAccessor, index, existingPrimary)
249
+ // Fresh row: hand the mount point to the row's own `createComponent` so
250
+ // its `init` observes a connected element. A renderItem body that clones a
251
+ // template instead of calling `createComponent` (composite / plain loops)
252
+ // leaves it unconsumed, hence the restore below rather than a plain clear.
253
+ //
254
+ // Only touch the ambient when we are the one setting it, and put back what
255
+ // was there rather than `null`: this row's `init` can drive a nested
256
+ // `mapArray`, and blanking the slot on the way out of the inner list would
257
+ // strand an outer mount point that no `createComponent` had claimed yet.
258
+ const ownsRowMount = !existingPrimary && !!rowMount
259
+ const prevRowMount = ownsRowMount ? setRowMountPoint(rowMount) : null
260
+ try {
261
+ primaryEl = renderItem(itemAccessor, index, existingPrimary)
262
+ } finally {
263
+ if (ownsRowMount) setRowMountPoint(prevRowMount)
264
+ }
242
265
  if (existingPrimary) {
243
266
  extras = existingExtras ?? []
244
267
  startMarker = existingStart ?? null
@@ -253,6 +276,15 @@ function createItemScope<T>(
253
276
  return undefined
254
277
  })
255
278
 
279
+ // A multi-root row's extras and per-item marker only exist once `renderItem`
280
+ // has returned, so a parked primary would be left in the DOM without them
281
+ // (and the LIS reorder may then keep it stationary and never insert them).
282
+ // Multi-root bodies never take the `createComponent` row path, so this is
283
+ // expected to be dead — un-park rather than emit a half-inserted row.
284
+ if (rowMount && !existingPrimary && extras.length > 0 && primaryEl.parentNode) {
285
+ primaryEl.remove()
286
+ }
287
+
256
288
  return { startMarker, primaryEl, extras, dispose, setItem }
257
289
  }
258
290
 
@@ -343,7 +375,9 @@ export function mapArray<T>(
343
375
  for (let i = existingRanges.length; i < items.length; i++) {
344
376
  const item = items[i]
345
377
  const key = getKey ? getKey(item, i) : String(i)
346
- const scope = createItemScope(item, i, renderItem)
378
+ // Final position is known here (append before the loop's trailing
379
+ // anchor), so the row can be mounted at it before its init runs.
380
+ const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
347
381
  if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
348
382
  scopes.set(key, scope)
349
383
  insertScope(scope, container, anchor)
@@ -454,8 +488,11 @@ export function mapArray<T>(
454
488
  existing.setItem(item)
455
489
  desiredOrder.push(existing)
456
490
  } else {
457
- // New item: create in isolated scope
458
- const scope = createItemScope(item, i, renderItem)
491
+ // New item: create in isolated scope. The row is mounted at the end of
492
+ // the loop range before its init runs; the LIS reorder below moves it
493
+ // to its final position (it participates in the walk like any other
494
+ // attached scope, so the resulting order is unchanged).
495
+ const scope = createItemScope(item, i, renderItem, undefined, undefined, undefined, { container, anchor })
459
496
  if (!scope.primaryEl.dataset.key) scope.primaryEl.setAttribute(BF_KEY, key)
460
497
  scopes.set(key, scope)
461
498
  desiredOrder.push(scope)
@@ -481,8 +518,10 @@ export function mapArray<T>(
481
518
  // in the DOM at all) is grouped into contiguous runs and inserted with
482
519
  // ONE insertBefore per run (a DocumentFragment when a run has more than
483
520
  // one scope). A swap of two rows becomes exactly two single-scope
484
- // moves; a bulk append becomes one fragment insert that never touches
485
- // the existing rows; an unchanged order performs zero DOM mutations.
521
+ // moves; a bulk append of unattached rows becomes one fragment insert
522
+ // that never touches the existing rows; an unchanged order performs zero
523
+ // DOM mutations. (Component rows arrive here already attached — see the
524
+ // note on `domOrderIndices` below.)
486
525
  //
487
526
  // Moving elements via insertBefore causes detach/reattach which makes
488
527
  // focused inputs lose focus (controlled input flicker) — scopes kept
@@ -494,9 +533,27 @@ export function mapArray<T>(
494
533
  }
495
534
 
496
535
  // Old DOM order of currently-attached scopes, expressed as desired-order
497
- // indices. Brand-new scopes aren't attached yet, so they simply never
498
- // appear here — which is exactly what marks them for insertion below.
499
- // Single O(n) walk, no Array.from allocation.
536
+ // indices. Single O(n) walk, no Array.from allocation.
537
+ //
538
+ // A brand-new scope appears here only if it is already attached, which is
539
+ // exactly the case for a row whose root is a `createComponent` — those are
540
+ // parked at `anchor` before their `init` runs (see `createItemScope`). Both
541
+ // cases are correct, and neither needs special-casing: the walk reports the
542
+ // live DOM, which is the only thing the LIS argument rests on. An unattached
543
+ // new scope is absent, so it is non-stationary and gets inserted below; an
544
+ // attached one is present at its parked position and the LIS decides
545
+ // whether it already sits where it belongs.
546
+ //
547
+ // Consequence worth knowing: for a bulk append of component rows the parked
548
+ // order already equals the desired order, so the LIS keeps every row
549
+ // stationary and this step performs ZERO mutations — the insertions have
550
+ // simply moved earlier, one `insertBefore` per row inside
551
+ // `createComponent`, instead of one batched fragment insert here. That is
552
+ // inherent to connecting before `init`, not an oversight: a row's `init`
553
+ // runs during its own `renderItem`, so the row must already be in the live
554
+ // document by then, and a shared fragment is not the live document.
555
+ // Deferring init to regain the batch is the approach that failed — see
556
+ // attempt 1 in the `csr-loop-row-init-connected.test.ts` docstring.
500
557
  const domOrderIndices: number[] = []
501
558
  for (
502
559
  let node: Node | null = startMarker ? startMarker.nextSibling : container.firstChild;
@@ -25,6 +25,13 @@
25
25
  * is `null` and step 2 yields nothing. Reading `__bfExtras` lets
26
26
  * lookups reach the still-pending extras before `mapArray` inserts
27
27
  * them into the DOM.
28
+ *
29
+ * Step 3's reliance on the primary being detached during setup is why
30
+ * `createItemScope` un-parks a row that turns out to carry extras: the
31
+ * connect-before-init mount point applies to `createComponent` row roots,
32
+ * which are single-root by construction and so never reach this module. An
33
+ * attached primary would make step 2's sibling walk run past the item's own
34
+ * roots into a neighbouring item's elements before step 3 is ever consulted.
28
35
  */
29
36
 
30
37
  import { BF_LOOP_ITEM, BF_LOOP_START, BF_LOOP_END } from '@barefootjs/shared'
@@ -120,9 +127,8 @@ export function upsertChildItem(
120
127
  const ph = qsaItem(primaryEl, `[data-bf-ph="${phId}"]`) as HTMLElement | null
121
128
  if (ph) {
122
129
  const slot = slotId ? buildSlotInfo(primaryEl, slotId, anchorScope) : undefined
123
- const comp = createComponent(name, props, key, slot)
124
- ph.replaceWith(comp)
125
- return comp
130
+ // Connect before init see the same call in `upsertChild`.
131
+ return createComponent(name, props, key, slot, ph)
126
132
  }
127
133
  return null
128
134
  }
@@ -158,9 +158,11 @@ export function upsertChild(
158
158
  : parent.querySelector(`[${BF_PLACEHOLDER}="${phId}"]`)) as HTMLElement | null
159
159
  if (ph) {
160
160
  const slot = slotId ? buildSlotInfo(parent, slotId, anchorScope) : undefined
161
- const comp = createComponent(name, props, key, slot)
162
- ph.replaceWith(comp)
163
- return comp
161
+ // Hand the placeholder to `createComponent` so the new element is
162
+ // connected before its init runs — `useContext` resolves by DOM
163
+ // position, and a detached init silently fell back to the global
164
+ // context store.
165
+ return createComponent(name, props, key, slot, ph)
164
166
  }
165
167
  return null
166
168
  }