@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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. 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
- }