@pyreon/runtime-dom 0.1.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/nodes.ts ADDED
@@ -0,0 +1,741 @@
1
+ import type { VNode, VNodeChild } from "@pyreon/core"
2
+
3
+ type MountFn = (child: VNodeChild, parent: Node, anchor: Node | null) => Cleanup
4
+
5
+ import { effect, runUntracked } from "@pyreon/reactivity"
6
+
7
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
8
+
9
+ type Cleanup = () => void
10
+
11
+ /**
12
+ * Move all nodes strictly between `start` and `end` into a throwaway
13
+ * DocumentFragment, detaching them from the live DOM in O(n) top-level moves.
14
+ *
15
+ * This is dramatically faster than Range.deleteContents() in JS-based DOMs
16
+ * (happy-dom, jsdom) where deleting connected nodes with deep subtrees is O(n²).
17
+ * In real browsers both approaches are similar, but the fragment approach is
18
+ * never slower and avoids the pathological case.
19
+ *
20
+ * After this call every moved node has isConnected=false, so cleanup functions
21
+ * that guard removeChild with `isConnected !== false` become no-ops.
22
+ */
23
+ function clearBetween(start: Node, end: Node): void {
24
+ const frag = document.createDocumentFragment()
25
+ let cur: Node | null = start.nextSibling
26
+ while (cur && cur !== end) {
27
+ const next: Node | null = cur.nextSibling
28
+ frag.appendChild(cur)
29
+ cur = next
30
+ }
31
+ // frag goes out of scope → nodes are GC-eligible
32
+ }
33
+
34
+ /**
35
+ * Mount a reactive node whose content changes over time.
36
+ *
37
+ * A comment node is used as a stable anchor point in the DOM.
38
+ * On each change: old nodes are removed, new ones inserted before the anchor.
39
+ */
40
+ export function mountReactive(
41
+ accessor: () => VNodeChild,
42
+ parent: Node,
43
+ anchor: Node | null,
44
+ mount: (child: VNodeChild, p: Node, a: Node | null) => Cleanup,
45
+ ): Cleanup {
46
+ const marker = document.createComment("pyreon")
47
+ parent.insertBefore(marker, anchor)
48
+
49
+ let currentCleanup: Cleanup = () => {
50
+ /* noop */
51
+ }
52
+ let generation = 0
53
+
54
+ const e = effect(() => {
55
+ const myGen = ++generation
56
+ // Run cleanup outside tracking context — cleanup may write to signals
57
+ // (e.g. onUnmount hooks), and those writes must not accidentally register
58
+ // as dependencies of this effect, which would cause infinite recursion.
59
+ runUntracked(() => currentCleanup())
60
+ currentCleanup = () => {
61
+ /* noop */
62
+ }
63
+ const value = accessor()
64
+ if (__DEV__ && typeof value === "function") {
65
+ console.warn(
66
+ "[Pyreon] Reactive accessor returned a function instead of a value. Did you forget to call the signal?",
67
+ )
68
+ }
69
+ if (value != null && value !== false) {
70
+ const cleanup = mount(value, parent, marker)
71
+ // Guard: a re-entrant signal update (e.g. ErrorBoundary catching a child
72
+ // throw) may have already re-run this effect and updated currentCleanup.
73
+ // In that case, discard our stale cleanup rather than overwriting the one
74
+ // set by the re-entrant run.
75
+ if (myGen === generation) {
76
+ currentCleanup = cleanup
77
+ } else {
78
+ cleanup()
79
+ }
80
+ }
81
+ })
82
+
83
+ return () => {
84
+ e.dispose()
85
+ currentCleanup()
86
+ marker.parentNode?.removeChild(marker)
87
+ }
88
+ }
89
+
90
+ // ─── Keyed list reconciler ────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Efficient keyed list reconciler.
94
+ *
95
+ * When a reactive accessor returns VNode[] where every vnode carries a key,
96
+ * this reconciler reuses, moves, and creates DOM nodes surgically instead of
97
+ * tearing down and rebuilding the full list on every signal update.
98
+ */
99
+
100
+ interface KeyedEntry {
101
+ /** Comment node placed immediately before this entry's DOM content. */
102
+ anchor: Comment
103
+ cleanup: Cleanup
104
+ }
105
+
106
+ // WeakSets to identify anchor nodes belonging to list entries.
107
+ // Entries use their first DOM node as anchor (element for simple vnodes, comment fallback for empty).
108
+ const _keyedAnchors = new WeakSet<Node>()
109
+
110
+ /** LIS-based reorder state — shared across keyed list instances, grown as needed */
111
+ interface LisState {
112
+ tails: Int32Array
113
+ tailIdx: Int32Array
114
+ pred: Int32Array
115
+ stay: Uint8Array
116
+ }
117
+
118
+ function growLisArrays(lis: LisState, n: number): LisState {
119
+ if (n <= lis.pred.length) return lis
120
+ return {
121
+ tails: new Int32Array(n + 16),
122
+ tailIdx: new Int32Array(n + 16),
123
+ pred: new Int32Array(n + 16),
124
+ stay: new Uint8Array(n + 16),
125
+ }
126
+ }
127
+
128
+ function computeKeyedLis(
129
+ lis: LisState,
130
+ n: number,
131
+ newKeyOrder: (string | number)[],
132
+ curPos: Map<string | number, number>,
133
+ ): number {
134
+ const { tails, tailIdx, pred } = lis
135
+ let lisLen = 0
136
+ for (let i = 0; i < n; i++) {
137
+ const key = newKeyOrder[i]
138
+ if (key === undefined) continue
139
+ const v = curPos.get(key) ?? -1
140
+ if (v < 0) continue
141
+
142
+ let lo = 0
143
+ let hi = lisLen
144
+ while (lo < hi) {
145
+ const mid = (lo + hi) >> 1
146
+ if ((tails[mid] as number) < v) lo = mid + 1
147
+ else hi = mid
148
+ }
149
+ tails[lo] = v
150
+ tailIdx[lo] = i
151
+ if (lo > 0) pred[i] = tailIdx[lo - 1] as number
152
+ if (lo === lisLen) lisLen++
153
+ }
154
+ return lisLen
155
+ }
156
+
157
+ function markStayingEntries(lis: LisState, lisLen: number): void {
158
+ const { tailIdx, pred, stay } = lis
159
+ let cur: number = lisLen > 0 ? (tailIdx[lisLen - 1] as number) : -1
160
+ while (cur !== -1) {
161
+ stay[cur] = 1
162
+ cur = pred[cur] as number
163
+ }
164
+ }
165
+
166
+ function applyKeyedMoves(
167
+ n: number,
168
+ newKeyOrder: (string | number)[],
169
+ stay: Uint8Array,
170
+ cache: Map<string | number, KeyedEntry>,
171
+ parent: Node,
172
+ tailMarker: Comment,
173
+ ): void {
174
+ let cursor: Node = tailMarker
175
+ for (let i = n - 1; i >= 0; i--) {
176
+ const key = newKeyOrder[i]
177
+ if (key === undefined) continue
178
+ const entry = cache.get(key)
179
+ if (!entry) continue
180
+ if (!stay[i]) moveEntryBefore(parent, entry.anchor, cursor)
181
+ cursor = entry.anchor
182
+ }
183
+ }
184
+
185
+ /** Grow LIS typed arrays if needed, then compute and apply reorder. */
186
+ function keyedListReorder(
187
+ lis: LisState,
188
+ n: number,
189
+ newKeyOrder: (string | number)[],
190
+ curPos: Map<string | number, number>,
191
+ cache: Map<string | number, KeyedEntry>,
192
+ parent: Node,
193
+ tailMarker: Comment,
194
+ ): LisState {
195
+ const grown = growLisArrays(lis, n)
196
+ grown.pred.fill(-1, 0, n)
197
+ grown.stay.fill(0, 0, n)
198
+
199
+ const lisLen = computeKeyedLis(grown, n, newKeyOrder, curPos)
200
+ markStayingEntries(grown, lisLen)
201
+ applyKeyedMoves(n, newKeyOrder, grown.stay, cache, parent, tailMarker)
202
+
203
+ return grown
204
+ }
205
+
206
+ export function mountKeyedList(
207
+ accessor: () => VNode[],
208
+ parent: Node,
209
+ listAnchor: Node | null,
210
+ mountVNode: (vnode: VNode, p: Node, a: Node | null) => Cleanup,
211
+ ): Cleanup {
212
+ const startMarker = document.createComment("")
213
+ const tailMarker = document.createComment("")
214
+ parent.insertBefore(startMarker, listAnchor)
215
+ parent.insertBefore(tailMarker, listAnchor)
216
+
217
+ const cache = new Map<string | number, KeyedEntry>()
218
+ const curPos = new Map<string | number, number>()
219
+ let currentKeyOrder: (string | number)[] = []
220
+
221
+ let lis: LisState = {
222
+ tails: new Int32Array(16),
223
+ tailIdx: new Int32Array(16),
224
+ pred: new Int32Array(16),
225
+ stay: new Uint8Array(16),
226
+ }
227
+
228
+ const collectKeyOrder = (
229
+ newList: VNode[],
230
+ ): { newKeyOrder: (string | number)[]; newKeySet: Set<string | number> } => {
231
+ const newKeyOrder: (string | number)[] = []
232
+ const newKeySet = new Set<string | number>()
233
+ for (const vnode of newList) {
234
+ const key = vnode.key
235
+ if (key !== null && key !== undefined) {
236
+ newKeyOrder.push(key)
237
+ newKeySet.add(key)
238
+ }
239
+ }
240
+ return { newKeyOrder, newKeySet }
241
+ }
242
+
243
+ const removeStaleEntries = (newKeySet: Set<string | number>) => {
244
+ for (const [key, entry] of cache) {
245
+ if (newKeySet.has(key)) continue
246
+ entry.cleanup()
247
+ entry.anchor.parentNode?.removeChild(entry.anchor)
248
+ cache.delete(key)
249
+ curPos.delete(key)
250
+ }
251
+ }
252
+
253
+ const mountNewEntries = (newList: VNode[]) => {
254
+ for (const vnode of newList) {
255
+ const key = vnode.key
256
+ if (key === null || key === undefined) continue
257
+ if (cache.has(key)) continue
258
+ const anchor = document.createComment("")
259
+ _keyedAnchors.add(anchor)
260
+ parent.insertBefore(anchor, tailMarker)
261
+ const cleanup = mountVNode(vnode, parent, tailMarker)
262
+ cache.set(key, { anchor, cleanup })
263
+ }
264
+ }
265
+
266
+ const e = effect(() => {
267
+ const newList = accessor()
268
+ const n = newList.length
269
+
270
+ if (n === 0 && cache.size > 0) {
271
+ for (const entry of cache.values()) entry.cleanup()
272
+ cache.clear()
273
+ curPos.clear()
274
+ currentKeyOrder = []
275
+ clearBetween(startMarker, tailMarker)
276
+ return
277
+ }
278
+
279
+ const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
280
+ removeStaleEntries(newKeySet)
281
+ mountNewEntries(newList)
282
+
283
+ if (currentKeyOrder.length > 0 && n > 0) {
284
+ lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker)
285
+ }
286
+
287
+ curPos.clear()
288
+ for (let i = 0; i < newKeyOrder.length; i++) {
289
+ const k = newKeyOrder[i]
290
+ if (k !== undefined) curPos.set(k, i)
291
+ }
292
+ currentKeyOrder = newKeyOrder
293
+ })
294
+
295
+ return () => {
296
+ e.dispose()
297
+ for (const entry of cache.values()) {
298
+ entry.cleanup()
299
+ entry.anchor.parentNode?.removeChild(entry.anchor)
300
+ }
301
+ cache.clear()
302
+ startMarker.parentNode?.removeChild(startMarker)
303
+ tailMarker.parentNode?.removeChild(tailMarker)
304
+ }
305
+ }
306
+
307
+ // ─── For — source-aware keyed reconciler ─────────────────────────────────────
308
+
309
+ /** Maximum number of displaced positions before falling back to full LIS. */
310
+ const SMALL_K = 8
311
+
312
+ // WeakSet to identify anchor nodes belonging to mountFor entries.
313
+ const _forAnchors = new WeakSet<Node>()
314
+
315
+ // anchor is the first DOM node of the entry (element for normal vnodes, comment fallback for empty).
316
+ // Using the element itself saves 1 createComment + 1 DOM node per entry.
317
+ // pos is merged here (instead of a separate Map) to halve Map operations.
318
+ // cleanup is null when the entry has no teardown work (saves function call overhead on clear).
319
+ interface ForEntry {
320
+ anchor: Node
321
+ cleanup: Cleanup | null
322
+ pos: number
323
+ }
324
+
325
+ /** Try small-k reorder; returns true if handled, false if LIS fallback needed. */
326
+ function trySmallKReorder(
327
+ n: number,
328
+ newKeys: (string | number)[],
329
+ currentKeys: (string | number)[],
330
+ cache: Map<string | number, ForEntry>,
331
+ liveParent: Node,
332
+ tailMarker: Comment,
333
+ ): boolean {
334
+ if (n !== currentKeys.length) return false
335
+ const diffs: number[] = []
336
+ for (let i = 0; i < n; i++) {
337
+ if (newKeys[i] !== currentKeys[i]) {
338
+ diffs.push(i)
339
+ if (diffs.length > SMALL_K) return false
340
+ }
341
+ }
342
+ if (diffs.length > 0) smallKPlace(liveParent, diffs, newKeys, cache, tailMarker)
343
+ for (const i of diffs) {
344
+ const cached = cache.get(newKeys[i] as string | number)
345
+ if (cached) cached.pos = i
346
+ }
347
+ return true
348
+ }
349
+
350
+ function computeForLis(
351
+ lis: LisState,
352
+ n: number,
353
+ newKeys: (string | number)[],
354
+ cache: Map<string | number, ForEntry>,
355
+ ): number {
356
+ const { tails, tailIdx, pred } = lis
357
+ let lisLen = 0
358
+ for (let i = 0; i < n; i++) {
359
+ const key = newKeys[i] as string | number
360
+ const v = cache.get(key)?.pos ?? 0
361
+ let lo = 0
362
+ let hi = lisLen
363
+ while (lo < hi) {
364
+ const mid = (lo + hi) >> 1
365
+ if ((tails[mid] as number) < v) lo = mid + 1
366
+ else hi = mid
367
+ }
368
+ tails[lo] = v
369
+ tailIdx[lo] = i
370
+ if (lo > 0) pred[i] = tailIdx[lo - 1] as number
371
+ if (lo === lisLen) lisLen++
372
+ }
373
+ return lisLen
374
+ }
375
+
376
+ function applyForMoves(
377
+ n: number,
378
+ newKeys: (string | number)[],
379
+ stay: Uint8Array,
380
+ cache: Map<string | number, ForEntry>,
381
+ liveParent: Node,
382
+ tailMarker: Comment,
383
+ ): void {
384
+ let cursor: Node = tailMarker
385
+ for (let i = n - 1; i >= 0; i--) {
386
+ const entry = cache.get(newKeys[i] as string | number)
387
+ if (!entry) continue
388
+ if (!stay[i]) moveEntryBefore(liveParent, entry.anchor, cursor)
389
+ cursor = entry.anchor
390
+ }
391
+ }
392
+
393
+ /** LIS-based reorder for mountFor. */
394
+ function forLisReorder(
395
+ lis: LisState,
396
+ n: number,
397
+ newKeys: (string | number)[],
398
+ cache: Map<string | number, ForEntry>,
399
+ liveParent: Node,
400
+ tailMarker: Comment,
401
+ ): LisState {
402
+ const grown = growLisArrays(lis, n)
403
+ grown.pred.fill(-1, 0, n)
404
+ grown.stay.fill(0, 0, n)
405
+
406
+ const lisLen = computeForLis(grown, n, newKeys, cache)
407
+ markStayingEntries(grown, lisLen)
408
+ applyForMoves(n, newKeys, grown.stay, cache, liveParent, tailMarker)
409
+
410
+ for (let i = 0; i < n; i++) {
411
+ const cached = cache.get(newKeys[i] as string | number)
412
+ if (cached) cached.pos = i
413
+ }
414
+
415
+ return grown
416
+ }
417
+
418
+ /**
419
+ * Keyed reconciler that works directly on the source item array.
420
+ *
421
+ * Optimizations:
422
+ * - Calls renderItem() only for NEW keys — 0 VNode allocations for reorders
423
+ * - Small-k fast path: if <= SMALL_K positions changed, skips LIS
424
+ * - Fast clear path: moves nodes to DocumentFragment for O(n) bulk detach
425
+ * - Fresh render fast path: skips stale-check and reorder on first render
426
+ */
427
+ export function mountFor<T>(
428
+ source: () => T[],
429
+ getKey: (item: T) => string | number,
430
+ renderItem: (item: T) => import("@pyreon/core").VNode | import("@pyreon/core").NativeItem,
431
+ parent: Node,
432
+ anchor: Node | null,
433
+ mountChild: MountFn,
434
+ ): Cleanup {
435
+ const startMarker = document.createComment("")
436
+ const tailMarker = document.createComment("")
437
+ parent.insertBefore(startMarker, anchor)
438
+ parent.insertBefore(tailMarker, anchor)
439
+
440
+ let cache = new Map<string | number, ForEntry>()
441
+ let currentKeys: (string | number)[] = []
442
+ let cleanupCount = 0
443
+ let anchorsRegistered = false
444
+
445
+ let lis: LisState = {
446
+ tails: new Int32Array(16),
447
+ tailIdx: new Int32Array(16),
448
+ pred: new Int32Array(16),
449
+ stay: new Uint8Array(16),
450
+ }
451
+
452
+ const warnDuplicateKeys = (seen: Set<string | number> | null, key: string | number) => {
453
+ if (!__DEV__ || !seen) return
454
+ if (seen.has(key)) {
455
+ console.warn(`[Pyreon] Duplicate key "${String(key)}" in <For> list. Keys must be unique.`)
456
+ }
457
+ seen.add(key)
458
+ }
459
+
460
+ /** Render item into container, update cache+cleanupCount. No anchor registration. */
461
+ const renderInto = (
462
+ item: T,
463
+ key: string | number,
464
+ pos: number,
465
+ container: Node,
466
+ before: Node | null,
467
+ ) => {
468
+ const result = renderItem(item)
469
+ if ((result as import("@pyreon/core").NativeItem).__isNative) {
470
+ const native = result as import("@pyreon/core").NativeItem
471
+ container.insertBefore(native.el, before)
472
+ cache.set(key, { anchor: native.el, cleanup: native.cleanup, pos })
473
+ if (native.cleanup) cleanupCount++
474
+ return
475
+ }
476
+ const priorLast = before ? before.previousSibling : container.lastChild
477
+ const cl = mountChild(result as import("@pyreon/core").VNode, container, before)
478
+ const firstMounted = priorLast ? priorLast.nextSibling : container.firstChild
479
+ if (!firstMounted || firstMounted === before) {
480
+ const ph = document.createComment("")
481
+ container.insertBefore(ph, before)
482
+ cache.set(key, { anchor: ph, cleanup: cl, pos })
483
+ } else {
484
+ cache.set(key, { anchor: firstMounted, cleanup: cl, pos })
485
+ }
486
+ cleanupCount++
487
+ }
488
+
489
+ const handleFreshRender = (items: T[], n: number, liveParent: Node) => {
490
+ const frag = document.createDocumentFragment()
491
+ const keys = new Array<string | number>(n)
492
+ const _seenKeys = __DEV__ ? new Set<string | number>() : null
493
+ for (let i = 0; i < n; i++) {
494
+ const item = items[i] as T
495
+ const key = getKey(item)
496
+ warnDuplicateKeys(_seenKeys, key)
497
+ keys[i] = key
498
+ renderInto(item, key, i, frag, null)
499
+ }
500
+ liveParent.insertBefore(frag, tailMarker)
501
+ anchorsRegistered = false
502
+ currentKeys = keys
503
+ }
504
+
505
+ const collectNewKeys = (items: T[], n: number): (string | number)[] => {
506
+ const newKeys = new Array<string | number>(n)
507
+ const _seenUpdate = __DEV__ ? new Set<string | number>() : null
508
+ for (let i = 0; i < n; i++) {
509
+ newKeys[i] = getKey(items[i] as T)
510
+ warnDuplicateKeys(_seenUpdate, newKeys[i] as string | number)
511
+ }
512
+ return newKeys
513
+ }
514
+
515
+ const handleReplaceAll = (
516
+ items: T[],
517
+ n: number,
518
+ newKeys: (string | number)[],
519
+ liveParent: Node,
520
+ ) => {
521
+ if (cleanupCount > 0) {
522
+ for (const entry of cache.values()) if (entry.cleanup) entry.cleanup()
523
+ }
524
+ cache = new Map()
525
+ cleanupCount = 0
526
+
527
+ const parentParent = liveParent.parentNode
528
+ const canSwap =
529
+ parentParent && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker
530
+
531
+ const frag = document.createDocumentFragment()
532
+ for (let i = 0; i < n; i++) {
533
+ renderInto(items[i] as T, newKeys[i] as string | number, i, frag, null)
534
+ }
535
+ anchorsRegistered = false
536
+
537
+ if (canSwap) {
538
+ const fresh = liveParent.cloneNode(false)
539
+ fresh.appendChild(startMarker)
540
+ fresh.appendChild(frag)
541
+ fresh.appendChild(tailMarker)
542
+ parentParent.replaceChild(fresh, liveParent)
543
+ } else {
544
+ clearBetween(startMarker, tailMarker)
545
+ liveParent.insertBefore(frag, tailMarker)
546
+ }
547
+ currentKeys = newKeys
548
+ }
549
+
550
+ const removeStaleForEntries = (newKeySet: Set<string | number>) => {
551
+ for (const [key, entry] of cache) {
552
+ if (newKeySet.has(key)) continue
553
+ if (entry.cleanup) {
554
+ entry.cleanup()
555
+ cleanupCount--
556
+ }
557
+ entry.anchor.parentNode?.removeChild(entry.anchor)
558
+ cache.delete(key)
559
+ }
560
+ }
561
+
562
+ const mountNewForEntries = (
563
+ items: T[],
564
+ n: number,
565
+ newKeys: (string | number)[],
566
+ liveParent: Node,
567
+ ) => {
568
+ for (let i = 0; i < n; i++) {
569
+ const key = newKeys[i] as string | number
570
+ if (cache.has(key)) continue
571
+ renderInto(items[i] as T, key, i, liveParent, tailMarker)
572
+ const entry = cache.get(key)
573
+ if (entry) _forAnchors.add(entry.anchor)
574
+ }
575
+ }
576
+
577
+ const handleFastClear = (liveParent: Node) => {
578
+ if (cache.size === 0) return
579
+ if (cleanupCount > 0) {
580
+ for (const entry of cache.values()) if (entry.cleanup) entry.cleanup()
581
+ }
582
+ const pp = liveParent.parentNode
583
+ if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
584
+ const fresh = liveParent.cloneNode(false)
585
+ fresh.appendChild(startMarker)
586
+ fresh.appendChild(tailMarker)
587
+ pp.replaceChild(fresh, liveParent)
588
+ } else {
589
+ clearBetween(startMarker, tailMarker)
590
+ }
591
+ cache = new Map()
592
+ cleanupCount = 0
593
+ currentKeys = []
594
+ }
595
+
596
+ const hasAnyKeptKey = (n: number, newKeys: (string | number)[]): boolean => {
597
+ for (let i = 0; i < n; i++) {
598
+ if (cache.has(newKeys[i] as string | number)) return true
599
+ }
600
+ return false
601
+ }
602
+
603
+ const handleIncrementalUpdate = (
604
+ items: T[],
605
+ n: number,
606
+ newKeys: (string | number)[],
607
+ liveParent: Node,
608
+ ) => {
609
+ removeStaleForEntries(new Set<string | number>(newKeys))
610
+ mountNewForEntries(items, n, newKeys, liveParent)
611
+
612
+ if (!anchorsRegistered) {
613
+ for (const entry of cache.values()) _forAnchors.add(entry.anchor)
614
+ anchorsRegistered = true
615
+ }
616
+
617
+ if (trySmallKReorder(n, newKeys, currentKeys, cache, liveParent, tailMarker)) {
618
+ currentKeys = newKeys
619
+ return
620
+ }
621
+
622
+ lis = forLisReorder(lis, n, newKeys, cache, liveParent, tailMarker)
623
+ currentKeys = newKeys
624
+ }
625
+
626
+ const e = effect(() => {
627
+ const liveParent = startMarker.parentNode
628
+ if (!liveParent) return
629
+ const items = source()
630
+ const n = items.length
631
+
632
+ if (n === 0) {
633
+ handleFastClear(liveParent)
634
+ return
635
+ }
636
+
637
+ if (currentKeys.length === 0) {
638
+ handleFreshRender(items, n, liveParent)
639
+ return
640
+ }
641
+
642
+ const newKeys = collectNewKeys(items, n)
643
+
644
+ if (!hasAnyKeptKey(n, newKeys)) {
645
+ handleReplaceAll(items, n, newKeys, liveParent)
646
+ return
647
+ }
648
+
649
+ handleIncrementalUpdate(items, n, newKeys, liveParent)
650
+ })
651
+
652
+ return () => {
653
+ e.dispose()
654
+ for (const entry of cache.values()) {
655
+ if (cleanupCount > 0 && entry.cleanup) entry.cleanup()
656
+ entry.anchor.parentNode?.removeChild(entry.anchor)
657
+ }
658
+ cache = new Map()
659
+ cleanupCount = 0
660
+ startMarker.parentNode?.removeChild(startMarker)
661
+ tailMarker.parentNode?.removeChild(tailMarker)
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Small-k reorder: directly place the k displaced entries without LIS.
667
+ */
668
+ function smallKPlace(
669
+ parent: Node,
670
+ diffs: number[],
671
+ newKeys: (string | number)[],
672
+ cache: Map<string | number, { anchor: Node; cleanup: Cleanup | null }>,
673
+ tailMarker: Comment,
674
+ ): void {
675
+ const diffSet = new Set(diffs)
676
+ let cursor: Node = tailMarker
677
+ let prevDiffIdx = newKeys.length
678
+
679
+ for (let d = diffs.length - 1; d >= 0; d--) {
680
+ const i = diffs[d] as number
681
+
682
+ let nextNonDiff = -1
683
+ for (let j = i + 1; j < prevDiffIdx; j++) {
684
+ if (!diffSet.has(j)) {
685
+ nextNonDiff = j
686
+ break
687
+ }
688
+ }
689
+
690
+ if (nextNonDiff >= 0) {
691
+ const nc = cache.get(newKeys[nextNonDiff] as string | number)?.anchor
692
+ if (nc) cursor = nc
693
+ }
694
+
695
+ const entry = cache.get(newKeys[i] as string | number)
696
+ if (!entry) {
697
+ prevDiffIdx = i
698
+ continue
699
+ }
700
+ moveEntryBefore(parent, entry.anchor, cursor)
701
+ cursor = entry.anchor
702
+ prevDiffIdx = i
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Move startNode and all siblings belonging to this entry to just before `before`.
708
+ * Stops at the next entry anchor (identified via WeakSet) or the tail marker.
709
+ *
710
+ * Fast path: if the next sibling is already a boundary (another entry or tail),
711
+ * this entry is a single node — skip the toMove array entirely.
712
+ */
713
+ function moveEntryBefore(parent: Node, startNode: Node, before: Node): void {
714
+ const next = startNode.nextSibling
715
+ // Single-node fast path (covers all createTemplate rows — the common case)
716
+ if (
717
+ !next ||
718
+ next === before ||
719
+ (next.parentNode === parent && (_forAnchors.has(next) || _keyedAnchors.has(next)))
720
+ ) {
721
+ parent.insertBefore(startNode, before)
722
+ return
723
+ }
724
+ // Multi-node slow path (fragments, components with multiple root nodes)
725
+ const toMove: Node[] = [startNode]
726
+ let cur: Node | null = next
727
+ while (cur && cur !== before) {
728
+ const nextNode: Node | null = cur.nextSibling
729
+ toMove.push(cur)
730
+ cur = nextNode
731
+ if (
732
+ cur &&
733
+ cur.parentNode === parent &&
734
+ (cur === before || _forAnchors.has(cur) || _keyedAnchors.has(cur))
735
+ )
736
+ break
737
+ }
738
+ for (const node of toMove) {
739
+ parent.insertBefore(node, before)
740
+ }
741
+ }