@barefootjs/client 0.27.0 → 0.28.0
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
|
@@ -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
|
+
}
|
package/src/runtime/map-array.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
// the existing rows; an unchanged order performs zero
|
|
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.
|
|
498
|
-
//
|
|
499
|
-
//
|
|
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;
|
package/src/runtime/qsa-item.ts
CHANGED
|
@@ -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
|
-
|
|
124
|
-
ph
|
|
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
|
}
|
package/src/runtime/registry.ts
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|