@barefootjs/client 0.17.1 → 0.18.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/src/reactive.ts CHANGED
@@ -88,8 +88,16 @@ export interface ProfilerEventSink {
88
88
  turnEnd(): void
89
89
  }
90
90
 
91
- /** A signal's subscriber set, tagged with the signal's id for edge-removal events. */
92
- type SubscriberSet = Set<EffectContext> & { __bfSignalId?: string }
91
+ /**
92
+ * A signal's subscriber set, tagged with the signal's id for edge-removal
93
+ * events. `__bfSnapshot` is a dispatch-order cache for `set()` (perf): the
94
+ * array `[...subscribers]` would otherwise allocate on every write, but a
95
+ * stable subscriber graph (the common case — see `runEffect`'s dependency
96
+ * sweep) never mutates this Set between writes, so the same array can be
97
+ * reused. `undefined`/`null` means "no valid cache, rebuild on next write";
98
+ * every call site that adds or removes a member sets it back to `null`.
99
+ */
100
+ type SubscriberSet = Set<EffectContext> & { __bfSignalId?: string; __bfSnapshot?: EffectContext[] | null }
93
101
 
94
102
  let profilerSink: ProfilerEventSink | null = null
95
103
  let signalSeq = 0
@@ -142,9 +150,34 @@ export function __bfReportOutput(changed: boolean): void {
142
150
  type EffectContext = {
143
151
  fn: EffectFn
144
152
  cleanup: CleanupFn | null
145
- dependencies: Set<SubscriberSet>
153
+ // Signals read by the most recently completed run, mapped to the `gen`
154
+ // stamp they were (re)confirmed at. A re-run that reads the SAME signal
155
+ // again just refreshes its stamp in `get()` (no Set.delete/add on the
156
+ // signal's subscriber Set); one that stops reading a signal leaves that
157
+ // entry stamped with a stale `gen`, which the end-of-run sweep in
158
+ // `runEffect` detects and removes (perf: this is what makes a stable
159
+ // dependency graph — the overwhelmingly common case — free of the
160
+ // unsubscribe/resubscribe churn a naive clear-and-rebuild pays on every
161
+ // single run, while keeping dynamic dependency tracking exact).
162
+ dependencies: Map<SubscriberSet, number>
163
+ // Monotonic per-effect run counter, bumped once per invocation (including
164
+ // reentrant/circular ones — each nested call gets its own higher value).
165
+ // Stamped onto `dependencies` entries in `get()`; compared against by the
166
+ // OUTERMOST invocation's end-of-run sweep to find entries the most recent
167
+ // run didn't touch. Never compared across effects.
168
+ gen: number
146
169
  owner: EffectContext | null // Parent scope for hierarchical disposal
147
- children: EffectContext[] // Owned child effects/roots
170
+ // Owned child effects/roots. A `Set` (not an array) so a single child's
171
+ // detach in `disposeEffect` is O(1) — `mapArray` disposes large sibling
172
+ // groups one scope at a time (list clear/replace), and an array would need
173
+ // an `indexOf` scan per removal, making that O(n²) in the sibling count
174
+ // (bulk-disposal perf). Insertion order is preserved (Set iterates in
175
+ // insertion order), so cascade disposal in `disposeSubtree` still walks
176
+ // children in the same order they were created. Lazily allocated — most
177
+ // effects never gain a child (leaf effects/memos), so `null` until the
178
+ // first `owner.children.add(...)` avoids a per-effect Set allocation on
179
+ // the common path (perf).
180
+ children: Set<EffectContext> | null
148
181
  disposed: boolean
149
182
  runCount: number // Per-effect re-entry counter for circular dependency detection
150
183
  id: string // Dev instrumentation id ('' when profiling is off)
@@ -161,6 +194,16 @@ let Owner: EffectContext | null = null
161
194
  let Listener: EffectContext | null = null
162
195
  const MAX_EFFECT_RUNS = 100
163
196
 
197
+ /**
198
+ * Register `child` under `owner`, lazily allocating `owner.children` on the
199
+ * first registration (perf: most effects never gain a child, so this keeps
200
+ * the common leaf-effect path allocation-free).
201
+ */
202
+ function registerChild(owner: EffectContext, child: EffectContext): void {
203
+ if (!owner.children) owner.children = new Set()
204
+ owner.children.add(child)
205
+ }
206
+
164
207
  let BatchDepth = 0
165
208
  const PendingEffects = new Set<EffectContext>()
166
209
 
@@ -186,9 +229,33 @@ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
186
229
 
187
230
  const get = () => {
188
231
  if (Listener) {
189
- subscribers.add(Listener)
190
- Listener.dependencies.add(subscribers)
191
- if (profilerSink) profilerSink.subscribeAdd(id, Listener.id)
232
+ if (profilerSink) {
233
+ // Exact historical event contract: unconditionally (re)add and fire
234
+ // `subscribeAdd` for every read call, even a signal read twice
235
+ // within the same run body — profiler consumers must see the same
236
+ // stream regardless of the perf path below (dev-only instrumentation,
237
+ // its cost doesn't matter). See profiler-instrumentation.test.ts.
238
+ // Always invalidates `__bfSnapshot`, even on a redundant re-add of an
239
+ // already-present member — cheap here (profiling is already off the
240
+ // fast path) and correctness-critical: a real new member (e.g. this
241
+ // signal previously had zero subscribers, cached an empty snapshot,
242
+ // and gains its first one here) must not leave a stale empty cache
243
+ // behind for `set()` to reuse.
244
+ subscribers.add(Listener)
245
+ subscribers.__bfSnapshot = null
246
+ Listener.dependencies.set(subscribers, Listener.gen)
247
+ profilerSink.subscribeAdd(id, Listener.id)
248
+ } else if (!Listener.dependencies.has(subscribers)) {
249
+ // New dependency for this effect (first time it's read this signal,
250
+ // or it stopped and started again) — the only case that needs to
251
+ // touch the signal's subscriber Set. An already-tracked dependency
252
+ // (the common case) just gets its stamp refreshed below.
253
+ subscribers.add(Listener)
254
+ subscribers.__bfSnapshot = null
255
+ Listener.dependencies.set(subscribers, Listener.gen)
256
+ } else {
257
+ Listener.dependencies.set(subscribers, Listener.gen)
258
+ }
192
259
  }
193
260
  return value
194
261
  }
@@ -211,9 +278,25 @@ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
211
278
  PendingEffects.add(effect)
212
279
  }
213
280
  } else {
214
- const effectsToRun = [...subscribers]
215
- for (const effect of effectsToRun) {
216
- runEffect(effect)
281
+ // Snapshot the subscriber list before dispatch so an effect that
282
+ // subscribes/unsubscribes DURING this write's dispatch (a nested
283
+ // re-run, or a disposal triggered from an effect body) can't change
284
+ // what THIS write runs — same fixed-at-dispatch-time semantics as the
285
+ // previous per-write `[...subscribers]` copy (pinned in
286
+ // reactive.test.ts's "dispatch snapshot" cases).
287
+ //
288
+ // The array is cached on the Set itself and only rebuilt when
289
+ // membership actually changes (`get()`'s new-dependency branch, the
290
+ // end-of-run sweep, and disposal all null it out) — a stable
291
+ // subscriber graph (effects that read the same signals every run,
292
+ // the common case) reuses the same array across every subsequent
293
+ // write instead of paying a fresh allocation + copy each time.
294
+ let snapshot = subscribers.__bfSnapshot
295
+ if (!snapshot) {
296
+ snapshot = subscribers.__bfSnapshot = [...subscribers]
297
+ }
298
+ for (let i = 0; i < snapshot.length; i++) {
299
+ runEffect(snapshot[i]!)
217
300
  }
218
301
  }
219
302
  }
@@ -241,9 +324,10 @@ export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: Subscriber
241
324
  const effect: EffectContext = {
242
325
  fn,
243
326
  cleanup: null,
244
- dependencies: new Set(),
327
+ dependencies: new Map(),
328
+ gen: 0,
245
329
  owner: Owner,
246
- children: [],
330
+ children: null,
247
331
  disposed: false,
248
332
  runCount: 0,
249
333
  id: __bfId ?? (profilerSink ? `e${++subscriberSeq}` : ''),
@@ -255,7 +339,7 @@ export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: Subscriber
255
339
  if (profilerSink) profilerSink.effectCreate(effect.id, effect.kind)
256
340
 
257
341
  // Register with parent owner for hierarchical disposal
258
- if (Owner) Owner.children.push(effect)
342
+ if (Owner) registerChild(Owner, effect)
259
343
 
260
344
  runEffect(effect)
261
345
  }
@@ -268,6 +352,15 @@ function runEffect(effect: EffectContext): void {
268
352
  effect.runCount = 0
269
353
  throw new Error(`Circular dependency detected: effect re-entered itself ${MAX_EFFECT_RUNS} times.`)
270
354
  }
355
+ // Captured right after the reentrancy counter above so a nested re-entrant
356
+ // run of this SAME effect (the circular-dependency guard above bounds how
357
+ // deep that can go) doesn't run the end-of-run dependency sweep against
358
+ // its own, shallower run: only the outermost call sweeps, using whatever
359
+ // `effect.gen` the deepest/most-recently-completed nested run left behind
360
+ // — that run's reads are the authoritative "current dependencies" for the
361
+ // whole reentrant chain, exactly like the old clear-at-every-entry code's
362
+ // last write won.
363
+ const isOutermostRun = effect.runCount === 1
271
364
 
272
365
  // `effectEnter` is emitted *before* cleanup so the whole run — cleanup
273
366
  // included — is bracketed by enter/exit. A `set()` performed inside a cleanup
@@ -282,16 +375,30 @@ function runEffect(effect: EffectContext): void {
282
375
  effect.cleanup = null
283
376
  }
284
377
 
285
- for (const dep of effect.dependencies) {
286
- dep.delete(effect)
287
- if (profilerSink) profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
378
+ if (profilerSink) {
379
+ // Dev-instrumented path only: reproduce the exact pre-optimization event
380
+ // contract by dropping every dependency up front, so `get()` re-adds (and
381
+ // re-fires `subscribeAdd` for) everything the run reads — including a
382
+ // signal read twice in one body. Profiling is dev-only and its cost
383
+ // doesn't matter; what matters is that turning it on never changes the
384
+ // event stream a consumer sees. The perf path below (no profiler) uses
385
+ // the cheaper end-of-run sweep instead.
386
+ for (const dep of effect.dependencies.keys()) {
387
+ dep.delete(effect)
388
+ // Membership changed — a dep this run doesn't re-read would otherwise
389
+ // leave a stale cached dispatch snapshot that still contains this
390
+ // effect (re-reads re-add via `get()`, which re-invalidates anyway).
391
+ dep.__bfSnapshot = null
392
+ profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
393
+ }
394
+ effect.dependencies.clear()
288
395
  }
289
- effect.dependencies.clear()
290
396
 
291
397
  const prevOwner = Owner
292
398
  const prevListener = Listener
293
399
  Owner = effect
294
400
  Listener = effect
401
+ effect.gen++
295
402
 
296
403
  // Fresh output fingerprint for this run (§4.2.2); `__bfReportOutput` fills it.
297
404
  effect.outputReported = false
@@ -310,6 +417,25 @@ function runEffect(effect: EffectContext): void {
310
417
  Owner = prevOwner
311
418
  Listener = prevListener
312
419
  effect.runCount--
420
+
421
+ if (!profilerSink && isOutermostRun) {
422
+ // Sweep: a dependency whose stamp isn't the current `effect.gen`
423
+ // wasn't (re)read by the most recent run of this effect's body, so it
424
+ // must be dropped — this is what keeps dynamic dependency tracking
425
+ // exact (an effect that stops reading a signal stops depending on it).
426
+ // A dependency that IS still read every run (the overwhelmingly common
427
+ // case: most effects' read set never changes) never reaches this
428
+ // branch at all — `get()` above just refreshed its stamp, no
429
+ // Set.delete/add round trip on the signal's subscriber Set.
430
+ for (const [dep, gen] of effect.dependencies) {
431
+ if (gen !== effect.gen) {
432
+ effect.dependencies.delete(dep)
433
+ dep.delete(effect)
434
+ dep.__bfSnapshot = null
435
+ }
436
+ }
437
+ }
438
+
313
439
  if (profilerSink) {
314
440
  profilerSink.effectExit(effect.id, performance.now() - start)
315
441
  if (effect.outputReported) profilerSink.effectOutput?.(effect.id, effect.outputChanged)
@@ -327,18 +453,21 @@ function disposeSubtree(effect: EffectContext): void {
327
453
  if (effect.disposed) return
328
454
  effect.disposed = true
329
455
 
330
- for (const child of effect.children) {
331
- disposeSubtree(child)
456
+ if (effect.children) {
457
+ for (const child of effect.children) {
458
+ disposeSubtree(child)
459
+ }
460
+ effect.children.clear()
332
461
  }
333
- effect.children.length = 0
334
462
 
335
463
  if (effect.cleanup) {
336
464
  effect.cleanup()
337
465
  effect.cleanup = null
338
466
  }
339
467
 
340
- for (const dep of effect.dependencies) {
468
+ for (const dep of effect.dependencies.keys()) {
341
469
  dep.delete(effect)
470
+ dep.__bfSnapshot = null
342
471
  if (profilerSink) profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
343
472
  }
344
473
  effect.dependencies.clear()
@@ -350,19 +479,23 @@ function disposeSubtree(effect: EffectContext): void {
350
479
 
351
480
  /**
352
481
  * Public dispose entry point: detach `effect` from its parent's `children`
353
- * list, then dispose the subtree rooted at `effect`. The detach step lives
482
+ * set, then dispose the subtree rooted at `effect`. The detach step lives
354
483
  * only here so the recursion (which doesn't need it — the parent clears
355
- * `children.length = 0` itself) cannot splice entries out of the array
356
- * it is iterating. The splice-during-iter shape was the root cause of
484
+ * the whole set itself via `children.clear()`) cannot mutate the set
485
+ * it is iterating. The mutate-during-iterate shape was the root cause of
357
486
  * #1366; separating the two responsibilities makes it structurally
358
487
  * unreachable.
488
+ *
489
+ * `Set.delete` is O(1), so detaching one child from a parent that owns many
490
+ * siblings (e.g. `mapArray` disposing a cleared/replaced keyed list one
491
+ * `createRoot` scope at a time) no longer costs an `indexOf` scan over the
492
+ * remaining siblings — that scan was O(n) per disposal, O(n²) for the batch.
359
493
  */
360
494
  function disposeEffect(effect: EffectContext): void {
361
495
  if (effect.disposed) return
362
496
 
363
497
  if (effect.owner) {
364
- const idx = effect.owner.children.indexOf(effect)
365
- if (idx >= 0) effect.owner.children.splice(idx, 1)
498
+ effect.owner.children?.delete(effect)
366
499
  }
367
500
 
368
501
  disposeSubtree(effect)
@@ -382,9 +515,10 @@ export function createRoot<T>(fn: (dispose: () => void) => T): T {
382
515
  const root: EffectContext = {
383
516
  fn: () => {},
384
517
  cleanup: null,
385
- dependencies: new Set(),
518
+ dependencies: new Map(),
519
+ gen: 0,
386
520
  owner: Owner,
387
- children: [],
521
+ children: null,
388
522
  disposed: false,
389
523
  runCount: 0,
390
524
  id: profilerSink ? `r${++subscriberSeq}` : '',
@@ -395,7 +529,7 @@ export function createRoot<T>(fn: (dispose: () => void) => T): T {
395
529
 
396
530
  if (profilerSink) profilerSink.effectCreate(root.id, 'root')
397
531
 
398
- if (Owner) Owner.children.push(root)
532
+ if (Owner) registerChild(Owner, root)
399
533
 
400
534
  const prevOwner = Owner
401
535
  const prevListener = Listener
@@ -425,9 +559,10 @@ export function createDisposableEffect(fn: EffectFn, __bfId?: string): () => voi
425
559
  return fn()
426
560
  },
427
561
  cleanup: null,
428
- dependencies: new Set(),
562
+ dependencies: new Map(),
563
+ gen: 0,
429
564
  owner: Owner,
430
- children: [],
565
+ children: null,
431
566
  disposed: false,
432
567
  runCount: 0,
433
568
  id: __bfId ?? (profilerSink ? `e${++subscriberSeq}` : ''),
@@ -438,7 +573,7 @@ export function createDisposableEffect(fn: EffectFn, __bfId?: string): () => voi
438
573
 
439
574
  if (profilerSink) profilerSink.effectCreate(effect.id, effect.kind)
440
575
 
441
- if (Owner) Owner.children.push(effect)
576
+ if (Owner) registerChild(Owner, effect)
442
577
 
443
578
  runEffect(effect)
444
579
 
@@ -463,12 +463,23 @@ export function escapeAttr(value: unknown): string {
463
463
  * slots). The HTML spec only requires `& < >` in text, but the SSR
464
464
  * adapters (Hono) escape text with the same set as attribute values
465
465
  * (`& " ' < >`), and the fixture-hydrate / CSR-conformance layer requires
466
- * byte-parity with the server-rendered output — so this delegates to
467
- * `escapeAttr`. Kept as a distinct export so generated code reads
466
+ * byte-parity with the server-rendered output — so the escaping delegates
467
+ * to `escapeAttr`. Kept as a distinct export so generated code reads
468
468
  * `escapeText(...)` at text sites (self-documenting) and so the two
469
- * contexts can diverge later without touching call sites.
469
+ * contexts can diverge (as they now do for nullish) without touching call
470
+ * sites.
471
+ *
472
+ * A nullish value renders as empty text — the JSX/Solid semantics the Hono
473
+ * SSR reference follows (`{undefined}` / `{null}` produce no text), and
474
+ * what the reactive text-update path already does (`dynamic-text.ts` and
475
+ * `client-marker.ts` both `String(value ?? '')`). Only this initial-render
476
+ * escape site used to stringify `undefined` / `null` into literal
477
+ * "undefined" / "null" text, so a bare `{props.x}` on an absent prop
478
+ * diverged from SSR at first paint (#2137). Non-nullish values (including
479
+ * `0` and `false`) keep their `String()` form, matching the reactive path.
470
480
  */
471
481
  export function escapeText(value: unknown): string {
482
+ if (value == null) return ''
472
483
  return escapeAttr(value)
473
484
  }
474
485
 
@@ -72,7 +72,11 @@ function findLoopMarkers(
72
72
  if (markerId) {
73
73
  const startVal = loopStartMarker(markerId)
74
74
  const endVal = loopEndMarker(markerId)
75
- for (const node of Array.from(container.childNodes)) {
75
+ // Walk via firstChild/nextSibling rather than Array.from(childNodes)
76
+ // this runs on every un-cached lookup (see `resolveMarkers` in
77
+ // `mapArray`), so avoiding the intermediate array allocation matters
78
+ // for large sibling counts.
79
+ for (let node = container.firstChild; node; node = node.nextSibling) {
76
80
  if (node.nodeType !== Node.COMMENT_NODE) continue
77
81
  const value = (node as Comment).nodeValue
78
82
  if (value === startVal) start = node as Comment
@@ -81,7 +85,7 @@ function findLoopMarkers(
81
85
  } else {
82
86
  const startPrefix = `${BF_LOOP_START}:`
83
87
  const endPrefix = `${BF_LOOP_END}:`
84
- for (const node of Array.from(container.childNodes)) {
88
+ for (let node = container.firstChild; node; node = node.nextSibling) {
85
89
  if (node.nodeType !== Node.COMMENT_NODE) continue
86
90
  const value = (node as Comment).nodeValue ?? ''
87
91
  if (!start && (value === BF_LOOP_START || value.startsWith(startPrefix))) {
@@ -140,14 +144,61 @@ function findItemRanges(start: Comment, end: Comment): Array<{
140
144
  }
141
145
 
142
146
  /**
143
- * Insert a scope's nodes into the container in their canonical order
147
+ * Insert a scope's nodes into `target` in their canonical order
144
148
  * (startMarker → primaryEl → extras). Idempotent — `insertBefore` on a
145
149
  * node already at the target position is a no-op.
150
+ *
151
+ * `target` is typed as `Node` (not `HTMLElement`) so callers can pass a
152
+ * `DocumentFragment` to batch several scopes into one subsequent
153
+ * `container.insertBefore(fragment, anchor)` call — see the minimal-move
154
+ * reorder in `mapArray`.
146
155
  */
147
- function insertScope<T>(scope: ItemScope<T>, container: HTMLElement, anchor: Node | null): void {
148
- if (scope.startMarker) container.insertBefore(scope.startMarker, anchor)
149
- container.insertBefore(scope.primaryEl, anchor)
150
- for (const ex of scope.extras) container.insertBefore(ex, anchor)
156
+ function insertScope<T>(scope: ItemScope<T>, target: Node, anchor: Node | null): void {
157
+ if (scope.startMarker) target.insertBefore(scope.startMarker, anchor)
158
+ target.insertBefore(scope.primaryEl, anchor)
159
+ for (const ex of scope.extras) target.insertBefore(ex, anchor)
160
+ }
161
+
162
+ /**
163
+ * Longest increasing subsequence, returned as ascending indices into `arr`.
164
+ * O(n log n) patience sorting with predecessor backtracking.
165
+ *
166
+ * Used by `mapArray`'s reorder step: `arr` holds, for each already-attached
167
+ * scope encountered while walking the live DOM in its current order, the
168
+ * scope's index in the *desired* order. The LIS of that array is the
169
+ * largest set of scopes whose relative order already matches the desired
170
+ * order — those scopes can stay exactly where they are; every other scope
171
+ * (plus any brand-new one) needs to move. This is the same strategy
172
+ * keyed-diff reconcilers in the udomdiff/Solid family use to turn an
173
+ * arbitrary reorder into a minimal set of DOM moves.
174
+ */
175
+ function longestIncreasingSubsequenceIndices(arr: number[]): number[] {
176
+ const n = arr.length
177
+ if (n === 0) return []
178
+ // tails[k] = index into `arr` of the smallest possible tail value for an
179
+ // increasing subsequence of length k + 1.
180
+ const tails: number[] = []
181
+ const predecessors: number[] = new Array(n).fill(-1)
182
+ for (let i = 0; i < n; i++) {
183
+ const value = arr[i]
184
+ let lo = 0
185
+ let hi = tails.length
186
+ while (lo < hi) {
187
+ const mid = (lo + hi) >>> 1
188
+ if (arr[tails[mid]] < value) lo = mid + 1
189
+ else hi = mid
190
+ }
191
+ if (lo > 0) predecessors[i] = tails[lo - 1]
192
+ if (lo === tails.length) tails.push(i)
193
+ else tails[lo] = i
194
+ }
195
+ const result: number[] = new Array(tails.length)
196
+ let k = tails[tails.length - 1]
197
+ for (let i = tails.length - 1; i >= 0; i--) {
198
+ result[i] = k
199
+ k = predecessors[k]
200
+ }
201
+ return result
151
202
  }
152
203
 
153
204
  /** Detach all of a scope's nodes from the DOM. */
@@ -229,11 +280,29 @@ export function mapArray<T>(
229
280
  const scopes = new Map<string, ItemScope<T>>()
230
281
  let hydrated = false
231
282
 
283
+ // Loop boundary markers are structural — this module never removes or
284
+ // re-inserts them — so they can be found once and reused across every
285
+ // effect run instead of rescanning `container.childNodes` on every
286
+ // reconcile. `isConnected` guards against the (unusual) case of the
287
+ // container itself being torn down and rebuilt out from under this
288
+ // closure; in that case we fall back to a fresh lookup.
289
+ let cachedStart: Comment | null = null
290
+ let cachedEnd: Comment | null = null
291
+ const resolveMarkers = (): { start: Comment | null; end: Comment | null } => {
292
+ if (cachedStart && cachedEnd && cachedStart.isConnected && cachedEnd.isConnected) {
293
+ return { start: cachedStart, end: cachedEnd }
294
+ }
295
+ const found = findLoopMarkers(container, markerId)
296
+ cachedStart = found.start
297
+ cachedEnd = found.end
298
+ return found
299
+ }
300
+
232
301
  createEffect(() => {
233
302
  const items = accessor()
234
303
  if (!items) return
235
304
 
236
- const { start: startMarker, end: endMarker } = findLoopMarkers(container, markerId)
305
+ const { start: startMarker, end: endMarker } = resolveMarkers()
237
306
  const anchor = endMarker ?? null
238
307
 
239
308
  // --- First run: hydrate SSR-rendered children ---
@@ -315,6 +384,43 @@ export function mapArray<T>(
315
384
  }
316
385
  }
317
386
 
387
+ // --- Fast path: clearing the whole list ---
388
+ // When every existing scope is being removed, dispose them all and then
389
+ // remove their DOM in one bulk operation instead of one `.remove()` per
390
+ // scope. Loop markers (and their bracketing range) are always preserved.
391
+ if (items.length === 0) {
392
+ if (scopes.size > 0) {
393
+ for (const scope of scopes.values()) scope.dispose()
394
+ if (startMarker && endMarker) {
395
+ // Scoped (or per-item-marker) range: the space between the
396
+ // markers is owned entirely by this list, so a single Range
397
+ // delete clears it without touching the markers themselves.
398
+ const range = document.createRange()
399
+ range.setStartAfter(startMarker)
400
+ range.setEndBefore(endMarker)
401
+ range.deleteContents()
402
+ } else {
403
+ // Unscoped: the list owns the container's children directly.
404
+ // Verify no foreign siblings snuck in before nuking everything —
405
+ // counting is O(n), same order as the disposal loop above, so it
406
+ // doesn't add an asymptotically new cost.
407
+ let expectedNodeCount = 0
408
+ for (const scope of scopes.values()) {
409
+ expectedNodeCount += 1 + scope.extras.length + (scope.startMarker ? 1 : 0)
410
+ }
411
+ let actualNodeCount = 0
412
+ for (let node = container.firstChild; node; node = node.nextSibling) actualNodeCount++
413
+ if (actualNodeCount === expectedNodeCount) {
414
+ container.textContent = ''
415
+ } else {
416
+ for (const scope of scopes.values()) removeScope(scope)
417
+ }
418
+ }
419
+ scopes.clear()
420
+ }
421
+ return
422
+ }
423
+
318
424
  // --- Key-based diff ---
319
425
  const newKeys = new Set<string>()
320
426
  // Distinct from `newKeys`: tracks which keys have ALREADY emitted a
@@ -365,30 +471,67 @@ export function mapArray<T>(
365
471
  }
366
472
  }
367
473
 
368
- // Reconcile DOM order: skip insertBefore entirely when order is unchanged.
474
+ // --- Reconcile DOM order: minimal-move, LIS-based ---
475
+ //
476
+ // Rather than "any mismatch reinserts every scope", find the longest
477
+ // run of already-attached scopes that are already in the right
478
+ // relative order — the longest increasing subsequence of their desired
479
+ // positions, walking the live DOM once — and never touch those. Every
480
+ // other scope (scopes that need to move, plus brand-new scopes not yet
481
+ // in the DOM at all) is grouped into contiguous runs and inserted with
482
+ // ONE insertBefore per run (a DocumentFragment when a run has more than
483
+ // 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.
486
+ //
369
487
  // Moving elements via insertBefore causes detach/reattach which makes
370
- // focused inputs lose focus (controlled input flicker). Each scope can
371
- // span multiple nodes (startMarker + primaryEl + extras), so the walk
372
- // consumes the full range when a primaryEl matches.
373
- let inOrder = true
374
- let checkNode: Node | null = startMarker ? startMarker.nextSibling : container.firstChild
375
- for (const scope of desiredOrder) {
376
- // Skip non-element nodes (comments, text) when looking for the primary element.
377
- while (checkNode && checkNode.nodeType !== Node.ELEMENT_NODE) checkNode = checkNode.nextSibling
378
- if (checkNode !== scope.primaryEl) { inOrder = false; break }
379
- // Advance past the rest of the scope's extras.
380
- checkNode = checkNode.nextSibling
381
- for (let i = 0; i < scope.extras.length; i++) {
382
- while (checkNode && checkNode.nodeType !== Node.ELEMENT_NODE) checkNode = checkNode.nextSibling
383
- if (checkNode !== scope.extras[i]) { inOrder = false; break }
384
- checkNode = checkNode.nextSibling
385
- }
386
- if (!inOrder) break
488
+ // focused inputs lose focus (controlled input flicker) scopes kept
489
+ // stationary by the LIS are provably never detached, so that guarantee
490
+ // still holds for them.
491
+ const primaryElToDesiredIndex = new Map<HTMLElement, number>()
492
+ for (let i = 0; i < desiredOrder.length; i++) {
493
+ primaryElToDesiredIndex.set(desiredOrder[i].primaryEl, i)
387
494
  }
388
- if (!inOrder) {
389
- for (const scope of desiredOrder) {
390
- insertScope(scope, container, anchor)
495
+
496
+ // 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.
500
+ const domOrderIndices: number[] = []
501
+ for (
502
+ let node: Node | null = startMarker ? startMarker.nextSibling : container.firstChild;
503
+ node && node !== anchor;
504
+ node = node.nextSibling
505
+ ) {
506
+ if (node.nodeType !== Node.ELEMENT_NODE) continue
507
+ const idx = primaryElToDesiredIndex.get(node as HTMLElement)
508
+ if (idx !== undefined) domOrderIndices.push(idx)
509
+ }
510
+
511
+ const stationary = new Array<boolean>(desiredOrder.length).fill(false)
512
+ for (const pos of longestIncreasingSubsequenceIndices(domOrderIndices)) {
513
+ stationary[domOrderIndices[pos]] = true
514
+ }
515
+
516
+ let i = 0
517
+ while (i < desiredOrder.length) {
518
+ if (stationary[i]) { i++; continue }
519
+ let j = i
520
+ while (j < desiredOrder.length && !stationary[j]) j++
521
+ // Insert this run immediately before the next stationary scope (which
522
+ // is already exactly where it needs to be), or before the loop's
523
+ // trailing anchor when the run reaches the end of the list.
524
+ const before = j < desiredOrder.length
525
+ ? (desiredOrder[j].startMarker ?? desiredOrder[j].primaryEl)
526
+ : anchor
527
+ if (j - i === 1) {
528
+ insertScope(desiredOrder[i], container, before)
529
+ } else {
530
+ const runFragment = document.createDocumentFragment()
531
+ for (let k = i; k < j; k++) insertScope(desiredOrder[k], runFragment, null)
532
+ container.insertBefore(runFragment, before)
391
533
  }
534
+ i = j
392
535
  }
393
536
  }, bfId)
394
537
  }