@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/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +1909 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +1845 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +355 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/devtools.ts +304 -0
- package/src/hydrate.ts +385 -0
- package/src/hydration-debug.ts +39 -0
- package/src/index.ts +43 -0
- package/src/keep-alive.ts +71 -0
- package/src/mount.ts +367 -0
- package/src/nodes.ts +741 -0
- package/src/props.ts +328 -0
- package/src/template.ts +81 -0
- package/src/tests/coverage-gaps.test.ts +2488 -0
- package/src/tests/coverage.test.ts +1123 -0
- package/src/tests/mount.test.ts +3098 -0
- package/src/tests/setup.ts +3 -0
- package/src/transition-group.ts +264 -0
- package/src/transition.ts +184 -0
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
|
+
}
|