@pyreon/runtime-dom 0.24.5 → 0.24.6
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/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
package/src/nodes.ts
DELETED
|
@@ -1,896 +0,0 @@
|
|
|
1
|
-
import type { VNode, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { captureContextStack, restoreContextStack } from '@pyreon/core'
|
|
3
|
-
|
|
4
|
-
type MountFn = (child: VNodeChild, parent: Node, anchor: Node | null) => Cleanup
|
|
5
|
-
|
|
6
|
-
import { effect, runUntracked } from '@pyreon/reactivity'
|
|
7
|
-
|
|
8
|
-
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
9
|
-
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
10
|
-
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
11
|
-
|
|
12
|
-
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
13
|
-
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
14
|
-
|
|
15
|
-
type Cleanup = () => void
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Move all nodes strictly between `start` and `end` into a throwaway
|
|
19
|
-
* DocumentFragment, detaching them from the live DOM in O(n) top-level moves.
|
|
20
|
-
*
|
|
21
|
-
* This is dramatically faster than Range.deleteContents() in JS-based DOMs
|
|
22
|
-
* (happy-dom, jsdom) where deleting connected nodes with deep subtrees is O(n²).
|
|
23
|
-
* In real browsers both approaches are similar, but the fragment approach is
|
|
24
|
-
* never slower and avoids the pathological case.
|
|
25
|
-
*
|
|
26
|
-
* After this call every moved node has isConnected=false, so cleanup functions
|
|
27
|
-
* that guard removeChild with `isConnected !== false` become no-ops.
|
|
28
|
-
*/
|
|
29
|
-
function clearBetween(start: Node, end: Node): void {
|
|
30
|
-
const frag = document.createDocumentFragment()
|
|
31
|
-
let cur: Node | null = start.nextSibling
|
|
32
|
-
while (cur && cur !== end) {
|
|
33
|
-
const next: Node | null = cur.nextSibling
|
|
34
|
-
frag.appendChild(cur)
|
|
35
|
-
cur = next
|
|
36
|
-
}
|
|
37
|
-
// frag goes out of scope → nodes are GC-eligible
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Emit `runtime.cleanup` once per registered mount cleanup that actually runs. */
|
|
41
|
-
function _emitCleanup(): void {
|
|
42
|
-
if (__DEV__) _countSink.__pyreon_count__?.('runtime.cleanup')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Mount a reactive node whose content changes over time.
|
|
47
|
-
*
|
|
48
|
-
* A comment node is used as a stable anchor point in the DOM.
|
|
49
|
-
* On each change: old nodes are removed, new ones inserted before the anchor.
|
|
50
|
-
*/
|
|
51
|
-
export function mountReactive(
|
|
52
|
-
accessor: () => VNodeChild,
|
|
53
|
-
parent: Node,
|
|
54
|
-
anchor: Node | null,
|
|
55
|
-
mount: (child: VNodeChild, p: Node, a: Node | null) => Cleanup,
|
|
56
|
-
): Cleanup {
|
|
57
|
-
if (__DEV__) _countSink.__pyreon_count__?.('runtime.mountReactive')
|
|
58
|
-
const marker = document.createComment('pyreon')
|
|
59
|
-
parent.insertBefore(marker, anchor)
|
|
60
|
-
|
|
61
|
-
// Capture the context stack at creation time — ancestor provide() calls
|
|
62
|
-
// have already run, so this snapshot contains all parent contexts.
|
|
63
|
-
// When the effect re-mounts children later (e.g. Show toggling on),
|
|
64
|
-
// we restore this snapshot so children see ancestor providers.
|
|
65
|
-
const contextSnapshot = captureContextStack()
|
|
66
|
-
|
|
67
|
-
let currentCleanup: Cleanup = () => {
|
|
68
|
-
/* noop */
|
|
69
|
-
}
|
|
70
|
-
// hasCleanup gates `runtime.cleanup` so we don't count the placeholder
|
|
71
|
-
// noop on the first effect run as a "cleanup invocation".
|
|
72
|
-
let hasCleanup = false
|
|
73
|
-
let generation = 0
|
|
74
|
-
|
|
75
|
-
const e = effect(() => {
|
|
76
|
-
const myGen = ++generation
|
|
77
|
-
// Run cleanup outside tracking context — cleanup may write to signals
|
|
78
|
-
// (e.g. onUnmount hooks), and those writes must not accidentally register
|
|
79
|
-
// as dependencies of this effect, which would cause infinite recursion.
|
|
80
|
-
if (hasCleanup) _emitCleanup()
|
|
81
|
-
runUntracked(() => currentCleanup())
|
|
82
|
-
currentCleanup = () => {
|
|
83
|
-
/* noop */
|
|
84
|
-
}
|
|
85
|
-
hasCleanup = false
|
|
86
|
-
const value = accessor()
|
|
87
|
-
// Note: typeof value === 'function' is a VALID return from a reactive
|
|
88
|
-
// accessor — it represents a nested `() => VNodeChild` accessor (the
|
|
89
|
-
// conditional rendering pattern: `{() => show() ? <A /> : null}`).
|
|
90
|
-
// mountChild handles function children by calling them reactively.
|
|
91
|
-
// Do NOT warn on function returns — they are handled correctly at
|
|
92
|
-
// runtime by mountChild's function branch (line 58 above).
|
|
93
|
-
if (value != null && value !== false) {
|
|
94
|
-
// Mount children UNTRACKED — signal reads during child component
|
|
95
|
-
// setup (useContext, useTheme, etc.) must NOT subscribe this
|
|
96
|
-
// mountReactive effect. Otherwise, any signal read during the
|
|
97
|
-
// entire child tree's setup becomes a dependency, causing full
|
|
98
|
-
// DOM teardown + remount on that signal's change.
|
|
99
|
-
//
|
|
100
|
-
// Child components set up their OWN effects for reactivity
|
|
101
|
-
// (e.g. DynamicStyled's class swap effect). Those effects track
|
|
102
|
-
// their own dependencies independently.
|
|
103
|
-
//
|
|
104
|
-
// Use the marker's LIVE parent (not the closure-captured `parent`):
|
|
105
|
-
// when this mountReactive was created inside a DocumentFragment that
|
|
106
|
-
// mountFor later moved into the live tree via `insertBefore(frag, ...)`,
|
|
107
|
-
// the captured `parent` becomes a stale reference to the now-empty
|
|
108
|
-
// fragment. The marker, in contrast, was moved with the fragment's
|
|
109
|
-
// contents and `marker.parentNode` reflects the current live parent.
|
|
110
|
-
// Falling back to the captured `parent` only when the marker is
|
|
111
|
-
// detached (cleanup edge case) preserves prior behavior.
|
|
112
|
-
const liveParent = marker.parentNode ?? parent
|
|
113
|
-
const cleanup = runUntracked(() =>
|
|
114
|
-
restoreContextStack(contextSnapshot, () => mount(value, liveParent, marker)),
|
|
115
|
-
)
|
|
116
|
-
// Guard: a re-entrant signal update (e.g. ErrorBoundary catching a child
|
|
117
|
-
// throw) may have already re-run this effect and updated currentCleanup.
|
|
118
|
-
// In that case, discard our stale cleanup rather than overwriting the one
|
|
119
|
-
// set by the re-entrant run.
|
|
120
|
-
if (myGen === generation) {
|
|
121
|
-
currentCleanup = cleanup
|
|
122
|
-
hasCleanup = true
|
|
123
|
-
} else {
|
|
124
|
-
_emitCleanup()
|
|
125
|
-
cleanup()
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
return () => {
|
|
131
|
-
e.dispose()
|
|
132
|
-
if (hasCleanup) _emitCleanup()
|
|
133
|
-
currentCleanup()
|
|
134
|
-
marker.parentNode?.removeChild(marker)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ─── Keyed list reconciler ────────────────────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Efficient keyed list reconciler.
|
|
142
|
-
*
|
|
143
|
-
* When a reactive accessor returns VNode[] where every vnode carries a key,
|
|
144
|
-
* this reconciler reuses, moves, and creates DOM nodes surgically instead of
|
|
145
|
-
* tearing down and rebuilding the full list on every signal update.
|
|
146
|
-
*/
|
|
147
|
-
|
|
148
|
-
interface KeyedEntry {
|
|
149
|
-
/** Comment node placed immediately before this entry's DOM content. */
|
|
150
|
-
anchor: Comment
|
|
151
|
-
cleanup: Cleanup
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// WeakSets to identify anchor nodes belonging to list entries.
|
|
155
|
-
// Entries use their first DOM node as anchor (element for simple vnodes, comment fallback for empty).
|
|
156
|
-
const _keyedAnchors = new WeakSet<Node>()
|
|
157
|
-
|
|
158
|
-
/** LIS-based reorder state — shared across keyed list instances, grown as needed */
|
|
159
|
-
interface LisState {
|
|
160
|
-
tails: Int32Array
|
|
161
|
-
tailIdx: Int32Array
|
|
162
|
-
pred: Int32Array
|
|
163
|
-
stay: Uint8Array
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function growLisArrays(lis: LisState, n: number): LisState {
|
|
167
|
-
if (n <= lis.pred.length) return lis
|
|
168
|
-
return {
|
|
169
|
-
tails: new Int32Array(n + 16),
|
|
170
|
-
tailIdx: new Int32Array(n + 16),
|
|
171
|
-
pred: new Int32Array(n + 16),
|
|
172
|
-
stay: new Uint8Array(n + 16),
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function computeKeyedLis(
|
|
177
|
-
lis: LisState,
|
|
178
|
-
n: number,
|
|
179
|
-
newKeyOrder: (string | number)[],
|
|
180
|
-
curPos: Map<string | number, number>,
|
|
181
|
-
): number {
|
|
182
|
-
const { tails, tailIdx, pred } = lis
|
|
183
|
-
let lisLen = 0
|
|
184
|
-
let ops = 0
|
|
185
|
-
for (let i = 0; i < n; i++) {
|
|
186
|
-
const key = newKeyOrder[i]
|
|
187
|
-
if (key === undefined) continue
|
|
188
|
-
const v = curPos.get(key) ?? -1
|
|
189
|
-
if (v < 0) continue
|
|
190
|
-
|
|
191
|
-
let lo = 0
|
|
192
|
-
let hi = lisLen
|
|
193
|
-
while (lo < hi) {
|
|
194
|
-
const mid = (lo + hi) >> 1
|
|
195
|
-
ops++
|
|
196
|
-
if ((tails[mid] as number) < v) lo = mid + 1
|
|
197
|
-
else hi = mid
|
|
198
|
-
}
|
|
199
|
-
tails[lo] = v
|
|
200
|
-
tailIdx[lo] = i
|
|
201
|
-
if (lo > 0) pred[i] = tailIdx[lo - 1] as number
|
|
202
|
-
if (lo === lisLen) lisLen++
|
|
203
|
-
}
|
|
204
|
-
if (__DEV__ && ops > 0) _countSink.__pyreon_count__?.('runtime.mountFor.lisOps', ops)
|
|
205
|
-
return lisLen
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function markStayingEntries(lis: LisState, lisLen: number): void {
|
|
209
|
-
const { tailIdx, pred, stay } = lis
|
|
210
|
-
let cur: number = lisLen > 0 ? (tailIdx[lisLen - 1] as number) : -1
|
|
211
|
-
while (cur !== -1) {
|
|
212
|
-
stay[cur] = 1
|
|
213
|
-
cur = pred[cur] as number
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function applyKeyedMoves(
|
|
218
|
-
n: number,
|
|
219
|
-
newKeyOrder: (string | number)[],
|
|
220
|
-
stay: Uint8Array,
|
|
221
|
-
cache: Map<string | number, KeyedEntry>,
|
|
222
|
-
parent: Node,
|
|
223
|
-
tailMarker: Comment,
|
|
224
|
-
): void {
|
|
225
|
-
let cursor: Node = tailMarker
|
|
226
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
227
|
-
const key = newKeyOrder[i]
|
|
228
|
-
if (key === undefined) continue
|
|
229
|
-
const entry = cache.get(key)
|
|
230
|
-
if (!entry) continue
|
|
231
|
-
if (!stay[i]) moveEntryBefore(parent, entry.anchor, cursor)
|
|
232
|
-
cursor = entry.anchor
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Grow LIS typed arrays if needed, then compute and apply reorder. */
|
|
237
|
-
function keyedListReorder(
|
|
238
|
-
lis: LisState,
|
|
239
|
-
n: number,
|
|
240
|
-
newKeyOrder: (string | number)[],
|
|
241
|
-
curPos: Map<string | number, number>,
|
|
242
|
-
cache: Map<string | number, KeyedEntry>,
|
|
243
|
-
parent: Node,
|
|
244
|
-
tailMarker: Comment,
|
|
245
|
-
): LisState {
|
|
246
|
-
const grown = growLisArrays(lis, n)
|
|
247
|
-
grown.pred.fill(-1, 0, n)
|
|
248
|
-
grown.stay.fill(0, 0, n)
|
|
249
|
-
|
|
250
|
-
const lisLen = computeKeyedLis(grown, n, newKeyOrder, curPos)
|
|
251
|
-
markStayingEntries(grown, lisLen)
|
|
252
|
-
applyKeyedMoves(n, newKeyOrder, grown.stay, cache, parent, tailMarker)
|
|
253
|
-
|
|
254
|
-
return grown
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function mountKeyedList(
|
|
258
|
-
accessor: () => VNode[],
|
|
259
|
-
parent: Node,
|
|
260
|
-
listAnchor: Node | null,
|
|
261
|
-
mountVNode: (vnode: VNode, p: Node, a: Node | null) => Cleanup,
|
|
262
|
-
): Cleanup {
|
|
263
|
-
const startMarker = document.createComment('')
|
|
264
|
-
const tailMarker = document.createComment('')
|
|
265
|
-
parent.insertBefore(startMarker, listAnchor)
|
|
266
|
-
parent.insertBefore(tailMarker, listAnchor)
|
|
267
|
-
|
|
268
|
-
const cache = new Map<string | number, KeyedEntry>()
|
|
269
|
-
const curPos = new Map<string | number, number>()
|
|
270
|
-
let currentKeyOrder: (string | number)[] = []
|
|
271
|
-
|
|
272
|
-
let lis: LisState = {
|
|
273
|
-
tails: new Int32Array(16),
|
|
274
|
-
tailIdx: new Int32Array(16),
|
|
275
|
-
pred: new Int32Array(16),
|
|
276
|
-
stay: new Uint8Array(16),
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const collectKeyOrder = (
|
|
280
|
-
newList: VNode[],
|
|
281
|
-
): { newKeyOrder: (string | number)[]; newKeySet: Set<string | number> } => {
|
|
282
|
-
const newKeyOrder: (string | number)[] = []
|
|
283
|
-
const newKeySet = new Set<string | number>()
|
|
284
|
-
for (const vnode of newList) {
|
|
285
|
-
const key = vnode.key
|
|
286
|
-
if (key !== null && key !== undefined) {
|
|
287
|
-
newKeyOrder.push(key)
|
|
288
|
-
newKeySet.add(key)
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return { newKeyOrder, newKeySet }
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const removeStaleEntries = (newKeySet: Set<string | number>) => {
|
|
295
|
-
for (const [key, entry] of cache) {
|
|
296
|
-
if (newKeySet.has(key)) continue
|
|
297
|
-
_emitCleanup()
|
|
298
|
-
entry.cleanup()
|
|
299
|
-
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
300
|
-
cache.delete(key)
|
|
301
|
-
curPos.delete(key)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const mountNewEntries = (newList: VNode[], liveParent: Node) => {
|
|
306
|
-
for (const vnode of newList) {
|
|
307
|
-
const key = vnode.key
|
|
308
|
-
if (key === null || key === undefined) continue
|
|
309
|
-
if (cache.has(key)) continue
|
|
310
|
-
const anchor = document.createComment('')
|
|
311
|
-
_keyedAnchors.add(anchor)
|
|
312
|
-
liveParent.insertBefore(anchor, tailMarker)
|
|
313
|
-
const cleanup = mountVNode(vnode, liveParent, tailMarker)
|
|
314
|
-
cache.set(key, { anchor, cleanup })
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const e = effect(() => {
|
|
319
|
-
const newList = accessor()
|
|
320
|
-
const n = newList.length
|
|
321
|
-
// Same untracking rationale as mountFor — see comment there. Child
|
|
322
|
-
// mounts via mountVNode must not re-track on this effect's run.
|
|
323
|
-
runUntracked(() => {
|
|
324
|
-
// Use the marker's LIVE parent (not the closure-captured `parent`).
|
|
325
|
-
// Same bug class fixed in #776 for mountReactive: when this
|
|
326
|
-
// mountKeyedList was created inside a DocumentFragment that mountFor
|
|
327
|
-
// later moved via `liveParent.insertBefore(frag, tailMarker)`, the
|
|
328
|
-
// captured `parent` becomes a stale reference to the now-empty
|
|
329
|
-
// fragment. The markers were moved with the fragment's contents
|
|
330
|
-
// and their `parentNode` reflects the current live parent.
|
|
331
|
-
// Fallback to the captured `parent` only when the marker is
|
|
332
|
-
// detached (cleanup edge case) preserves prior behavior.
|
|
333
|
-
const liveParent = tailMarker.parentNode ?? parent
|
|
334
|
-
|
|
335
|
-
if (n === 0 && cache.size > 0) {
|
|
336
|
-
for (const entry of cache.values()) {
|
|
337
|
-
_emitCleanup()
|
|
338
|
-
entry.cleanup()
|
|
339
|
-
}
|
|
340
|
-
cache.clear()
|
|
341
|
-
curPos.clear()
|
|
342
|
-
currentKeyOrder = []
|
|
343
|
-
clearBetween(startMarker, tailMarker)
|
|
344
|
-
return
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
|
|
348
|
-
removeStaleEntries(newKeySet)
|
|
349
|
-
mountNewEntries(newList, liveParent)
|
|
350
|
-
|
|
351
|
-
if (currentKeyOrder.length > 0 && n > 0) {
|
|
352
|
-
lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, liveParent, tailMarker)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
curPos.clear()
|
|
356
|
-
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
357
|
-
const k = newKeyOrder[i]
|
|
358
|
-
if (k !== undefined) curPos.set(k, i)
|
|
359
|
-
}
|
|
360
|
-
currentKeyOrder = newKeyOrder
|
|
361
|
-
})
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
return () => {
|
|
365
|
-
e.dispose()
|
|
366
|
-
for (const entry of cache.values()) {
|
|
367
|
-
_emitCleanup()
|
|
368
|
-
entry.cleanup()
|
|
369
|
-
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
370
|
-
}
|
|
371
|
-
cache.clear()
|
|
372
|
-
startMarker.parentNode?.removeChild(startMarker)
|
|
373
|
-
tailMarker.parentNode?.removeChild(tailMarker)
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ─── For — source-aware keyed reconciler ─────────────────────────────────────
|
|
378
|
-
|
|
379
|
-
/** Maximum number of displaced positions before falling back to full LIS. */
|
|
380
|
-
const SMALL_K = 8
|
|
381
|
-
|
|
382
|
-
// WeakSet to identify anchor nodes belonging to mountFor entries.
|
|
383
|
-
const _forAnchors = new WeakSet<Node>()
|
|
384
|
-
|
|
385
|
-
// anchor is the first DOM node of the entry (element for normal vnodes, comment fallback for empty).
|
|
386
|
-
// Using the element itself saves 1 createComment + 1 DOM node per entry.
|
|
387
|
-
// pos is merged here (instead of a separate Map) to halve Map operations.
|
|
388
|
-
// cleanup is null when the entry has no teardown work (saves function call overhead on clear).
|
|
389
|
-
interface ForEntry {
|
|
390
|
-
anchor: Node
|
|
391
|
-
cleanup: Cleanup | null
|
|
392
|
-
pos: number
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/** Try small-k reorder; returns true if handled, false if LIS fallback needed. */
|
|
396
|
-
function trySmallKReorder(
|
|
397
|
-
n: number,
|
|
398
|
-
newKeys: (string | number)[],
|
|
399
|
-
currentKeys: (string | number)[],
|
|
400
|
-
cache: Map<string | number, ForEntry>,
|
|
401
|
-
liveParent: Node,
|
|
402
|
-
tailMarker: Comment,
|
|
403
|
-
): boolean {
|
|
404
|
-
if (n !== currentKeys.length) return false
|
|
405
|
-
const diffs: number[] = []
|
|
406
|
-
for (let i = 0; i < n; i++) {
|
|
407
|
-
if (newKeys[i] !== currentKeys[i]) {
|
|
408
|
-
diffs.push(i)
|
|
409
|
-
if (diffs.length > SMALL_K) return false
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
if (diffs.length > 0) smallKPlace(liveParent, diffs, newKeys, cache, tailMarker)
|
|
413
|
-
for (const i of diffs) {
|
|
414
|
-
const cached = cache.get(newKeys[i] as string | number)
|
|
415
|
-
if (cached) cached.pos = i
|
|
416
|
-
}
|
|
417
|
-
return true
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function computeForLis(
|
|
421
|
-
lis: LisState,
|
|
422
|
-
n: number,
|
|
423
|
-
newKeys: (string | number)[],
|
|
424
|
-
cache: Map<string | number, ForEntry>,
|
|
425
|
-
): number {
|
|
426
|
-
const { tails, tailIdx, pred } = lis
|
|
427
|
-
let lisLen = 0
|
|
428
|
-
let ops = 0
|
|
429
|
-
// Two-tier fast path.
|
|
430
|
-
//
|
|
431
|
-
// Tier 1 — "extend LIS": if v > the current tail-of-tails, v becomes the
|
|
432
|
-
// new tail. O(1). Covers APPEND: positions [0..N-1] are strictly
|
|
433
|
-
// increasing → the whole sequence is the LIS, 0 probes.
|
|
434
|
-
//
|
|
435
|
-
// Tier 2 — "known slot": if v ≤ lastV but tails[v] === v already, the
|
|
436
|
-
// binary-search answer is provably lo = v (strict-increase invariant
|
|
437
|
-
// guarantees tails[v-1] < v, so v slots exactly at index v). O(1) too.
|
|
438
|
-
// Covers PREPEND: [new N rows, old M rows] produces positions [0..N-1,
|
|
439
|
-
// 0..M-1] — the second monotonic run replaces tails[0..M-1] at indices
|
|
440
|
-
// N..N+M-1 with zero probes each. Before this tier, 1k prepend was ~10k
|
|
441
|
-
// probes; with it, 0.
|
|
442
|
-
//
|
|
443
|
-
// Tier 3 — binary search fallback. Random shuffles and other mixed
|
|
444
|
-
// reorders pay the standard log₂(lisLen) per index.
|
|
445
|
-
//
|
|
446
|
-
// Safety: the `v < lisLen && tails[v] === v` check is a strict subset of
|
|
447
|
-
// "binary-search would return v", so it never produces a wrong answer on
|
|
448
|
-
// shufles — it just opportunistically avoids probing when the answer
|
|
449
|
-
// happens to be the index itself. No behaviour change, only fewer probes.
|
|
450
|
-
let lastV = -1
|
|
451
|
-
for (let i = 0; i < n; i++) {
|
|
452
|
-
const key = newKeys[i] as string | number
|
|
453
|
-
const v = cache.get(key)?.pos ?? 0
|
|
454
|
-
// Tier 1: extend LIS.
|
|
455
|
-
if (v > lastV) {
|
|
456
|
-
tails[lisLen] = v
|
|
457
|
-
tailIdx[lisLen] = i
|
|
458
|
-
if (lisLen > 0) pred[i] = tailIdx[lisLen - 1] as number
|
|
459
|
-
lisLen++
|
|
460
|
-
lastV = v
|
|
461
|
-
continue
|
|
462
|
-
}
|
|
463
|
-
// Tier 2: known slot for piecewise-monotonic patterns (prepend, etc.).
|
|
464
|
-
let lo: number
|
|
465
|
-
if (v < lisLen && (tails[v] as number) === v) {
|
|
466
|
-
lo = v
|
|
467
|
-
} else {
|
|
468
|
-
// Tier 3: binary search.
|
|
469
|
-
lo = 0
|
|
470
|
-
let hi = lisLen
|
|
471
|
-
while (lo < hi) {
|
|
472
|
-
const mid = (lo + hi) >> 1
|
|
473
|
-
ops++
|
|
474
|
-
if ((tails[mid] as number) < v) lo = mid + 1
|
|
475
|
-
else hi = mid
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
tails[lo] = v
|
|
479
|
-
tailIdx[lo] = i
|
|
480
|
-
if (lo > 0) pred[i] = tailIdx[lo - 1] as number
|
|
481
|
-
// v ≤ lastV here, so tails can't be extended: lo < lisLen always.
|
|
482
|
-
}
|
|
483
|
-
if (__DEV__ && ops > 0) _countSink.__pyreon_count__?.('runtime.mountFor.lisOps', ops)
|
|
484
|
-
return lisLen
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function applyForMoves(
|
|
488
|
-
n: number,
|
|
489
|
-
newKeys: (string | number)[],
|
|
490
|
-
stay: Uint8Array,
|
|
491
|
-
cache: Map<string | number, ForEntry>,
|
|
492
|
-
liveParent: Node,
|
|
493
|
-
tailMarker: Comment,
|
|
494
|
-
): void {
|
|
495
|
-
let cursor: Node = tailMarker
|
|
496
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
497
|
-
const entry = cache.get(newKeys[i] as string | number)
|
|
498
|
-
if (!entry) continue
|
|
499
|
-
if (!stay[i]) moveEntryBefore(liveParent, entry.anchor, cursor)
|
|
500
|
-
cursor = entry.anchor
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** LIS-based reorder for mountFor. */
|
|
505
|
-
function forLisReorder(
|
|
506
|
-
lis: LisState,
|
|
507
|
-
n: number,
|
|
508
|
-
newKeys: (string | number)[],
|
|
509
|
-
cache: Map<string | number, ForEntry>,
|
|
510
|
-
liveParent: Node,
|
|
511
|
-
tailMarker: Comment,
|
|
512
|
-
): LisState {
|
|
513
|
-
const grown = growLisArrays(lis, n)
|
|
514
|
-
grown.pred.fill(-1, 0, n)
|
|
515
|
-
grown.stay.fill(0, 0, n)
|
|
516
|
-
|
|
517
|
-
const lisLen = computeForLis(grown, n, newKeys, cache)
|
|
518
|
-
markStayingEntries(grown, lisLen)
|
|
519
|
-
applyForMoves(n, newKeys, grown.stay, cache, liveParent, tailMarker)
|
|
520
|
-
|
|
521
|
-
for (let i = 0; i < n; i++) {
|
|
522
|
-
const cached = cache.get(newKeys[i] as string | number)
|
|
523
|
-
if (cached) cached.pos = i
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
return grown
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Keyed reconciler that works directly on the source item array.
|
|
531
|
-
*
|
|
532
|
-
* Optimizations:
|
|
533
|
-
* - Calls renderItem() only for NEW keys — 0 VNode allocations for reorders
|
|
534
|
-
* - Small-k fast path: if <= SMALL_K positions changed, skips LIS
|
|
535
|
-
* - Fast clear path: moves nodes to DocumentFragment for O(n) bulk detach
|
|
536
|
-
* - Fresh render fast path: skips stale-check and reorder on first render
|
|
537
|
-
*/
|
|
538
|
-
export function mountFor<T>(
|
|
539
|
-
source: () => T[],
|
|
540
|
-
getKey: (item: T) => string | number,
|
|
541
|
-
renderItem: (item: T) => import('@pyreon/core').VNode | import('@pyreon/core').NativeItem,
|
|
542
|
-
parent: Node,
|
|
543
|
-
anchor: Node | null,
|
|
544
|
-
mountChild: MountFn,
|
|
545
|
-
): Cleanup {
|
|
546
|
-
const startMarker = document.createComment('')
|
|
547
|
-
const tailMarker = document.createComment('')
|
|
548
|
-
parent.insertBefore(startMarker, anchor)
|
|
549
|
-
parent.insertBefore(tailMarker, anchor)
|
|
550
|
-
|
|
551
|
-
let cache = new Map<string | number, ForEntry>()
|
|
552
|
-
let currentKeys: (string | number)[] = []
|
|
553
|
-
const _reusableKeySet = new Set<string | number>()
|
|
554
|
-
let cleanupCount = 0
|
|
555
|
-
let anchorsRegistered = false
|
|
556
|
-
|
|
557
|
-
let lis: LisState = {
|
|
558
|
-
tails: new Int32Array(16),
|
|
559
|
-
tailIdx: new Int32Array(16),
|
|
560
|
-
pred: new Int32Array(16),
|
|
561
|
-
stay: new Uint8Array(16),
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const warnForKey = (seen: Set<string | number> | null, key: string | number) => {
|
|
565
|
-
if (!seen) return
|
|
566
|
-
if (__DEV__ && key == null) {
|
|
567
|
-
console.warn(
|
|
568
|
-
'[Pyreon] <For> `by` function returned null/undefined. ' +
|
|
569
|
-
'Keys must be strings or numbers. Check your `by` prop.',
|
|
570
|
-
)
|
|
571
|
-
}
|
|
572
|
-
if (seen.has(key)) {
|
|
573
|
-
if (__DEV__) {
|
|
574
|
-
console.warn(`[Pyreon] Duplicate key "${String(key)}" in <For> list. Keys must be unique.`)
|
|
575
|
-
}
|
|
576
|
-
// In production: skip duplicate — use first occurrence only.
|
|
577
|
-
// Prevents silent DOM corruption from cache key collision.
|
|
578
|
-
return true
|
|
579
|
-
}
|
|
580
|
-
seen.add(key)
|
|
581
|
-
return false
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/** Render item into container, update cache+cleanupCount. No anchor registration. */
|
|
585
|
-
const renderInto = (
|
|
586
|
-
item: T,
|
|
587
|
-
key: string | number,
|
|
588
|
-
pos: number,
|
|
589
|
-
container: Node,
|
|
590
|
-
before: Node | null,
|
|
591
|
-
) => {
|
|
592
|
-
const result = renderItem(item)
|
|
593
|
-
if ((result as import('@pyreon/core').NativeItem).__isNative) {
|
|
594
|
-
const native = result as import('@pyreon/core').NativeItem
|
|
595
|
-
container.insertBefore(native.el, before)
|
|
596
|
-
cache.set(key, { anchor: native.el, cleanup: native.cleanup, pos })
|
|
597
|
-
if (native.cleanup) cleanupCount++
|
|
598
|
-
return
|
|
599
|
-
}
|
|
600
|
-
const priorLast = before ? before.previousSibling : container.lastChild
|
|
601
|
-
const cl = mountChild(result as import('@pyreon/core').VNode, container, before)
|
|
602
|
-
const firstMounted = priorLast ? priorLast.nextSibling : container.firstChild
|
|
603
|
-
if (!firstMounted || firstMounted === before) {
|
|
604
|
-
const ph = document.createComment('')
|
|
605
|
-
container.insertBefore(ph, before)
|
|
606
|
-
cache.set(key, { anchor: ph, cleanup: cl, pos })
|
|
607
|
-
} else {
|
|
608
|
-
cache.set(key, { anchor: firstMounted, cleanup: cl, pos })
|
|
609
|
-
}
|
|
610
|
-
cleanupCount++
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const handleFreshRender = (items: T[], n: number, liveParent: Node) => {
|
|
614
|
-
const frag = document.createDocumentFragment()
|
|
615
|
-
const keys = new Array<string | number>(n)
|
|
616
|
-
const _seenKeys = new Set<string | number>()
|
|
617
|
-
for (let i = 0; i < n; i++) {
|
|
618
|
-
const item = items[i] as T
|
|
619
|
-
const key = getKey(item)
|
|
620
|
-
if (warnForKey(_seenKeys, key)) continue // skip duplicate
|
|
621
|
-
keys[i] = key
|
|
622
|
-
renderInto(item, key, i, frag, null)
|
|
623
|
-
}
|
|
624
|
-
liveParent.insertBefore(frag, tailMarker)
|
|
625
|
-
anchorsRegistered = false
|
|
626
|
-
currentKeys = keys
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const collectNewKeys = (items: T[], n: number): (string | number)[] => {
|
|
630
|
-
const newKeys = new Array<string | number>(n)
|
|
631
|
-
const _seenUpdate = new Set<string | number>()
|
|
632
|
-
for (let i = 0; i < n; i++) {
|
|
633
|
-
newKeys[i] = getKey(items[i] as T)
|
|
634
|
-
warnForKey(_seenUpdate, newKeys[i] as string | number)
|
|
635
|
-
// Note: we don't skip here — keys array must match items array length.
|
|
636
|
-
// Duplicate keys in update will just cause cache collisions (first wins).
|
|
637
|
-
}
|
|
638
|
-
return newKeys
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const handleReplaceAll = (
|
|
642
|
-
items: T[],
|
|
643
|
-
n: number,
|
|
644
|
-
newKeys: (string | number)[],
|
|
645
|
-
liveParent: Node,
|
|
646
|
-
) => {
|
|
647
|
-
if (cleanupCount > 0) {
|
|
648
|
-
for (const entry of cache.values()) {
|
|
649
|
-
if (entry.cleanup) {
|
|
650
|
-
_emitCleanup()
|
|
651
|
-
entry.cleanup()
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
cache = new Map()
|
|
656
|
-
cleanupCount = 0
|
|
657
|
-
|
|
658
|
-
const parentParent = liveParent.parentNode
|
|
659
|
-
const canSwap =
|
|
660
|
-
parentParent && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker
|
|
661
|
-
|
|
662
|
-
const frag = document.createDocumentFragment()
|
|
663
|
-
for (let i = 0; i < n; i++) {
|
|
664
|
-
renderInto(items[i] as T, newKeys[i] as string | number, i, frag, null)
|
|
665
|
-
}
|
|
666
|
-
anchorsRegistered = false
|
|
667
|
-
|
|
668
|
-
if (canSwap) {
|
|
669
|
-
const fresh = liveParent.cloneNode(false)
|
|
670
|
-
fresh.appendChild(startMarker)
|
|
671
|
-
fresh.appendChild(frag)
|
|
672
|
-
fresh.appendChild(tailMarker)
|
|
673
|
-
parentParent.replaceChild(fresh, liveParent)
|
|
674
|
-
} else {
|
|
675
|
-
clearBetween(startMarker, tailMarker)
|
|
676
|
-
liveParent.insertBefore(frag, tailMarker)
|
|
677
|
-
}
|
|
678
|
-
currentKeys = newKeys
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const removeStaleForEntries = (newKeySet: Set<string | number>) => {
|
|
682
|
-
for (const [key, entry] of cache) {
|
|
683
|
-
if (newKeySet.has(key)) continue
|
|
684
|
-
if (entry.cleanup) {
|
|
685
|
-
_emitCleanup()
|
|
686
|
-
entry.cleanup()
|
|
687
|
-
cleanupCount--
|
|
688
|
-
}
|
|
689
|
-
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
690
|
-
cache.delete(key)
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const mountNewForEntries = (
|
|
695
|
-
items: T[],
|
|
696
|
-
n: number,
|
|
697
|
-
newKeys: (string | number)[],
|
|
698
|
-
liveParent: Node,
|
|
699
|
-
) => {
|
|
700
|
-
for (let i = 0; i < n; i++) {
|
|
701
|
-
const key = newKeys[i] as string | number
|
|
702
|
-
if (cache.has(key)) continue
|
|
703
|
-
renderInto(items[i] as T, key, i, liveParent, tailMarker)
|
|
704
|
-
const entry = cache.get(key)
|
|
705
|
-
if (entry) _forAnchors.add(entry.anchor)
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const handleFastClear = (liveParent: Node) => {
|
|
710
|
-
if (cache.size === 0) return
|
|
711
|
-
if (cleanupCount > 0) {
|
|
712
|
-
for (const entry of cache.values()) {
|
|
713
|
-
if (entry.cleanup) {
|
|
714
|
-
_emitCleanup()
|
|
715
|
-
entry.cleanup()
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
const pp = liveParent.parentNode
|
|
720
|
-
if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
|
|
721
|
-
const fresh = liveParent.cloneNode(false)
|
|
722
|
-
fresh.appendChild(startMarker)
|
|
723
|
-
fresh.appendChild(tailMarker)
|
|
724
|
-
pp.replaceChild(fresh, liveParent)
|
|
725
|
-
} else {
|
|
726
|
-
clearBetween(startMarker, tailMarker)
|
|
727
|
-
}
|
|
728
|
-
cache = new Map()
|
|
729
|
-
cleanupCount = 0
|
|
730
|
-
currentKeys = []
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
const hasAnyKeptKey = (n: number, newKeys: (string | number)[]): boolean => {
|
|
734
|
-
for (let i = 0; i < n; i++) {
|
|
735
|
-
if (cache.has(newKeys[i] as string | number)) return true
|
|
736
|
-
}
|
|
737
|
-
return false
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const handleIncrementalUpdate = (
|
|
741
|
-
items: T[],
|
|
742
|
-
n: number,
|
|
743
|
-
newKeys: (string | number)[],
|
|
744
|
-
liveParent: Node,
|
|
745
|
-
) => {
|
|
746
|
-
// Reuse a persistent Set to avoid allocating a new one per update.
|
|
747
|
-
// Cleared + repopulated instead of constructing new Set(newKeys).
|
|
748
|
-
_reusableKeySet.clear()
|
|
749
|
-
for (let i = 0; i < newKeys.length; i++) _reusableKeySet.add(newKeys[i] as string | number)
|
|
750
|
-
removeStaleForEntries(_reusableKeySet)
|
|
751
|
-
mountNewForEntries(items, n, newKeys, liveParent)
|
|
752
|
-
|
|
753
|
-
if (!anchorsRegistered) {
|
|
754
|
-
for (const entry of cache.values()) _forAnchors.add(entry.anchor)
|
|
755
|
-
anchorsRegistered = true
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
if (trySmallKReorder(n, newKeys, currentKeys, cache, liveParent, tailMarker)) {
|
|
759
|
-
currentKeys = newKeys
|
|
760
|
-
return
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
lis = forLisReorder(lis, n, newKeys, cache, liveParent, tailMarker)
|
|
764
|
-
currentKeys = newKeys
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const e = effect(() => {
|
|
768
|
-
const liveParent = startMarker.parentNode
|
|
769
|
-
if (!liveParent) return
|
|
770
|
-
const items = source()
|
|
771
|
-
const n = items.length
|
|
772
|
-
// Child mounts (renderInto → mountChild) must NOT re-track on this
|
|
773
|
-
// effect's run, mirroring mountReactive's pattern at line ~92. Without
|
|
774
|
-
// this, any signal read during a child component's setup (e.g. useQuery
|
|
775
|
-
// calling `new QueryObserver(client, options())` at construction time,
|
|
776
|
-
// which reads any signals inside the options builder) leaks its
|
|
777
|
-
// subscription up to the For effect. A flip of the unrelated signal
|
|
778
|
-
// re-runs For, runCleanup() disposes ALL inner effects, and
|
|
779
|
-
// handleIncrementalUpdate skips re-mount on key match — leaving the
|
|
780
|
-
// subtree's inner effects gone forever. Reproduced by the
|
|
781
|
-
// `<For>`-shaped test in fanout-repro.test.tsx; deferred from PR #490.
|
|
782
|
-
runUntracked(() => {
|
|
783
|
-
if (n === 0) {
|
|
784
|
-
handleFastClear(liveParent)
|
|
785
|
-
return
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (currentKeys.length === 0) {
|
|
789
|
-
handleFreshRender(items, n, liveParent)
|
|
790
|
-
return
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const newKeys = collectNewKeys(items, n)
|
|
794
|
-
|
|
795
|
-
if (!hasAnyKeptKey(n, newKeys)) {
|
|
796
|
-
handleReplaceAll(items, n, newKeys, liveParent)
|
|
797
|
-
return
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
handleIncrementalUpdate(items, n, newKeys, liveParent)
|
|
801
|
-
})
|
|
802
|
-
})
|
|
803
|
-
|
|
804
|
-
return () => {
|
|
805
|
-
e.dispose()
|
|
806
|
-
for (const entry of cache.values()) {
|
|
807
|
-
if (cleanupCount > 0 && entry.cleanup) {
|
|
808
|
-
_emitCleanup()
|
|
809
|
-
entry.cleanup()
|
|
810
|
-
}
|
|
811
|
-
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
812
|
-
}
|
|
813
|
-
cache = new Map()
|
|
814
|
-
cleanupCount = 0
|
|
815
|
-
startMarker.parentNode?.removeChild(startMarker)
|
|
816
|
-
tailMarker.parentNode?.removeChild(tailMarker)
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* Small-k reorder: directly place the k displaced entries without LIS.
|
|
822
|
-
*/
|
|
823
|
-
function smallKPlace(
|
|
824
|
-
parent: Node,
|
|
825
|
-
diffs: number[],
|
|
826
|
-
newKeys: (string | number)[],
|
|
827
|
-
cache: Map<string | number, { anchor: Node; cleanup: Cleanup | null }>,
|
|
828
|
-
tailMarker: Comment,
|
|
829
|
-
): void {
|
|
830
|
-
const diffSet = new Set(diffs)
|
|
831
|
-
let cursor: Node = tailMarker
|
|
832
|
-
let prevDiffIdx = newKeys.length
|
|
833
|
-
|
|
834
|
-
for (let d = diffs.length - 1; d >= 0; d--) {
|
|
835
|
-
const i = diffs[d] as number
|
|
836
|
-
|
|
837
|
-
let nextNonDiff = -1
|
|
838
|
-
for (let j = i + 1; j < prevDiffIdx; j++) {
|
|
839
|
-
if (!diffSet.has(j)) {
|
|
840
|
-
nextNonDiff = j
|
|
841
|
-
break
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
if (nextNonDiff >= 0) {
|
|
846
|
-
const nc = cache.get(newKeys[nextNonDiff] as string | number)?.anchor
|
|
847
|
-
if (nc) cursor = nc
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
const entry = cache.get(newKeys[i] as string | number)
|
|
851
|
-
if (!entry) {
|
|
852
|
-
prevDiffIdx = i
|
|
853
|
-
continue
|
|
854
|
-
}
|
|
855
|
-
moveEntryBefore(parent, entry.anchor, cursor)
|
|
856
|
-
cursor = entry.anchor
|
|
857
|
-
prevDiffIdx = i
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* Move startNode and all siblings belonging to this entry to just before `before`.
|
|
863
|
-
* Stops at the next entry anchor (identified via WeakSet) or the tail marker.
|
|
864
|
-
*
|
|
865
|
-
* Fast path: if the next sibling is already a boundary (another entry or tail),
|
|
866
|
-
* this entry is a single node — skip the toMove array entirely.
|
|
867
|
-
*/
|
|
868
|
-
function moveEntryBefore(parent: Node, startNode: Node, before: Node): void {
|
|
869
|
-
const next = startNode.nextSibling
|
|
870
|
-
// Single-node fast path (covers all createTemplate rows — the common case)
|
|
871
|
-
if (
|
|
872
|
-
!next ||
|
|
873
|
-
next === before ||
|
|
874
|
-
(next.parentNode === parent && (_forAnchors.has(next) || _keyedAnchors.has(next)))
|
|
875
|
-
) {
|
|
876
|
-
parent.insertBefore(startNode, before)
|
|
877
|
-
return
|
|
878
|
-
}
|
|
879
|
-
// Multi-node slow path (fragments, components with multiple root nodes)
|
|
880
|
-
const toMove: Node[] = [startNode]
|
|
881
|
-
let cur: Node | null = next
|
|
882
|
-
while (cur && cur !== before) {
|
|
883
|
-
const nextNode: Node | null = cur.nextSibling
|
|
884
|
-
toMove.push(cur)
|
|
885
|
-
cur = nextNode
|
|
886
|
-
if (
|
|
887
|
-
cur &&
|
|
888
|
-
cur.parentNode === parent &&
|
|
889
|
-
(cur === before || _forAnchors.has(cur) || _keyedAnchors.has(cur))
|
|
890
|
-
)
|
|
891
|
-
break
|
|
892
|
-
}
|
|
893
|
-
for (const node of toMove) {
|
|
894
|
-
parent.insertBefore(node, before)
|
|
895
|
-
}
|
|
896
|
-
}
|