@pyreon/runtime-dom 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -3,12 +3,27 @@
3
3
  export { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from './delegate'
4
4
  export type { DevtoolsComponentEntry, PyreonDevtools } from './devtools'
5
5
  export { hydrateRoot } from './hydrate'
6
- export { disableHydrationWarnings, enableHydrationWarnings } from './hydration-debug'
6
+ export type {
7
+ HydrationMismatchContext,
8
+ HydrationMismatchHandler,
9
+ HydrationMismatchType,
10
+ } from './hydration-debug'
11
+ export {
12
+ disableHydrationWarnings,
13
+ enableHydrationWarnings,
14
+ onHydrationMismatch,
15
+ } from './hydration-debug'
7
16
  export type { KeepAliveProps } from './keep-alive'
8
17
  export { KeepAlive } from './keep-alive'
9
18
  export { mountChild } from './mount'
10
19
  export type { SanitizeFn } from './props'
11
- export { applyProp, applyProps, applyProps as _applyProps, sanitizeHtml, setSanitizer } from './props'
20
+ export {
21
+ applyProp,
22
+ applyProps,
23
+ applyProps as _applyProps,
24
+ sanitizeHtml,
25
+ setSanitizer,
26
+ } from './props'
12
27
  export { _bindDirect, _bindText, _mountSlot, _tpl, createTemplate } from './template'
13
28
  export type { TransitionProps } from './transition'
14
29
  export { Transition } from './transition'
@@ -22,8 +37,10 @@ import { mountChild } from './mount'
22
37
 
23
38
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
24
39
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
25
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
26
- const __DEV__ = import.meta.env?.DEV === true
40
+ const __DEV__ = process.env.NODE_ENV !== 'production'
41
+
42
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
43
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
27
44
 
28
45
  /**
29
46
  * Mount a VNode tree into a container element.
@@ -39,10 +56,17 @@ export function mount(root: VNodeChild, container: Element): () => void {
39
56
  '[pyreon] mount() called with a null/undefined container. Make sure the element exists in the DOM, e.g. document.getElementById("app")',
40
57
  )
41
58
  }
42
- installDevTools()
59
+ if (__DEV__) {
60
+ _countSink.__pyreon_count__?.('runtime.mount')
61
+ installDevTools()
62
+ }
43
63
  setupDelegation(container)
44
64
  container.innerHTML = ''
45
- return mountChild(root, container, null)
65
+ const unmount = mountChild(root, container, null)
66
+ return () => {
67
+ if (__DEV__) _countSink.__pyreon_count__?.('runtime.unmount')
68
+ unmount()
69
+ }
46
70
  }
47
71
 
48
72
  /** Alias for `mount` */
@@ -0,0 +1,3 @@
1
+ // Subpath entry for @pyreon/runtime-dom/keep-alive
2
+ export type { KeepAliveProps } from './keep-alive'
3
+ export { KeepAlive } from './keep-alive'
package/src/keep-alive.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Props, VNodeChild } from '@pyreon/core'
2
- import { createRef, h, onMount } from '@pyreon/core'
2
+ import { createRef, h, nativeCompat, onMount } from '@pyreon/core'
3
3
  import { effect } from '@pyreon/reactivity'
4
4
  import { mountChild } from './mount'
5
5
 
@@ -70,3 +70,7 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
70
70
  // (children appear as if directly in the parent flow)
71
71
  return h('div', { ref: containerRef, style: 'display: contents' })
72
72
  }
73
+
74
+ // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
75
+ // KeepAlive uses onMount + effect + mountChild that need Pyreon's setup frame.
76
+ nativeCompat(KeepAlive)
package/src/mount.ts CHANGED
@@ -25,8 +25,10 @@ import { applyProps } from './props'
25
25
 
26
26
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
27
27
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
28
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
29
- const __DEV__ = import.meta.env?.DEV === true
28
+ const __DEV__ = process.env.NODE_ENV !== 'production'
29
+
30
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
31
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
30
32
 
31
33
  type Cleanup = () => void
32
34
  const noop: Cleanup = () => {
@@ -40,7 +42,9 @@ let _elementDepth = 0
40
42
 
41
43
  // Stack tracking which component is currently being mounted (depth-first order).
42
44
  // Used to infer parent/child relationships for DevTools.
43
- const _mountingStack: string[] = []
45
+ // Only allocated in dev — production mounts skip devtools entirely.
46
+ let _mountingStack: string[] | undefined
47
+ if (__DEV__) _mountingStack = []
44
48
 
45
49
  /**
46
50
  * Mount a single child into `parent`, inserting before `anchor` (null = append).
@@ -54,6 +58,7 @@ export function mountChild(
54
58
  parent: Node,
55
59
  anchor: Node | null = null,
56
60
  ): Cleanup {
61
+ if (__DEV__) _countSink.__pyreon_count__?.('runtime.mountChild')
57
62
  // Reactive accessor — function that reads signals
58
63
  if (typeof child === 'function') {
59
64
  const sample = runUntracked(() => (child as () => VNodeChild | VNodeChild[])())
@@ -72,7 +77,8 @@ export function mountChild(
72
77
  parent.insertBefore(text, anchor)
73
78
  const dispose = renderEffect(() => {
74
79
  const v = (child as () => unknown)()
75
- text.data = v == null || v === false ? '' : String(v as string | number)
80
+ const next = v == null || v === false ? '' : String(v as string | number)
81
+ if (next !== text.data) text.data = next
76
82
  })
77
83
  if (_elementDepth > 0) return dispose
78
84
  return () => {
@@ -125,10 +131,30 @@ export function mountChild(
125
131
  if (vnode.type === Fragment) return mountChildren(vnode.children ?? [], parent, anchor)
126
132
 
127
133
  if (vnode.type === (ForSymbol as unknown as string)) {
128
- const { each, by, children } = vnode.props as unknown as ForProps<unknown>
134
+ // Compiler wraps `<For each={signal}>` in `_rp(() => signal())`
135
+ // `props.each` is a getter that returns the resolved array, not the
136
+ // function. Destructuring eagerly captures the array and breaks
137
+ // reactivity + crashes mountFor (which calls `source()`). Read each
138
+ // lazily so the _rp getter fires inside mountFor's effect, preserving
139
+ // signal tracking. User-written `each={() => fn()}` (already a
140
+ // function, not _rp-wrapped) still works because props.each is the
141
+ // function itself.
142
+ const props = vnode.props as unknown as ForProps<unknown>
143
+ const initialEach = props.each as unknown
144
+ const source: () => unknown[] =
145
+ typeof initialEach === 'function'
146
+ ? (initialEach as () => unknown[])
147
+ : (() => props.each as unknown as unknown[])
129
148
  const prevDepth = _elementDepth
130
149
  _elementDepth = 0
131
- const cleanup = mountFor(each, by, children, parent, anchor, mountChild)
150
+ const cleanup = mountFor(
151
+ source as () => unknown[],
152
+ props.by,
153
+ props.children,
154
+ parent,
155
+ anchor,
156
+ mountChild,
157
+ )
132
158
  _elementDepth = prevDepth
133
159
  return cleanup
134
160
  }
@@ -189,21 +215,81 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'
189
215
 
190
216
  // Tags that require namespace-aware creation
191
217
  const SVG_TAGS = new Set([
192
- 'svg', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect',
193
- 'g', 'defs', 'symbol', 'use', 'text', 'tspan', 'textPath', 'image',
194
- 'clipPath', 'mask', 'pattern', 'marker', 'linearGradient', 'radialGradient',
195
- 'stop', 'filter', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
196
- 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap',
197
- 'feFlood', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode',
198
- 'feMorphology', 'feOffset', 'feSpecularLighting', 'feTile', 'feTurbulence',
199
- 'animate', 'animateMotion', 'animateTransform', 'set', 'desc', 'title',
200
- 'metadata', 'foreignObject',
218
+ 'svg',
219
+ 'circle',
220
+ 'ellipse',
221
+ 'line',
222
+ 'path',
223
+ 'polygon',
224
+ 'polyline',
225
+ 'rect',
226
+ 'g',
227
+ 'defs',
228
+ 'symbol',
229
+ 'use',
230
+ 'text',
231
+ 'tspan',
232
+ 'textPath',
233
+ 'image',
234
+ 'clipPath',
235
+ 'mask',
236
+ 'pattern',
237
+ 'marker',
238
+ 'linearGradient',
239
+ 'radialGradient',
240
+ 'stop',
241
+ 'filter',
242
+ 'feBlend',
243
+ 'feColorMatrix',
244
+ 'feComponentTransfer',
245
+ 'feComposite',
246
+ 'feConvolveMatrix',
247
+ 'feDiffuseLighting',
248
+ 'feDisplacementMap',
249
+ 'feFlood',
250
+ 'feGaussianBlur',
251
+ 'feImage',
252
+ 'feMerge',
253
+ 'feMergeNode',
254
+ 'feMorphology',
255
+ 'feOffset',
256
+ 'feSpecularLighting',
257
+ 'feTile',
258
+ 'feTurbulence',
259
+ 'animate',
260
+ 'animateMotion',
261
+ 'animateTransform',
262
+ 'set',
263
+ 'desc',
264
+ 'title',
265
+ 'metadata',
266
+ 'foreignObject',
201
267
  ])
202
268
 
203
269
  const MATHML_TAGS = new Set([
204
- 'math', 'mi', 'mo', 'mn', 'ms', 'mtext', 'mspace', 'mrow', 'mfrac',
205
- 'msqrt', 'mroot', 'msub', 'msup', 'msubsup', 'munder', 'mover',
206
- 'munderover', 'mtable', 'mtr', 'mtd', 'mpadded', 'mphantom', 'menclose',
270
+ 'math',
271
+ 'mi',
272
+ 'mo',
273
+ 'mn',
274
+ 'ms',
275
+ 'mtext',
276
+ 'mspace',
277
+ 'mrow',
278
+ 'mfrac',
279
+ 'msqrt',
280
+ 'mroot',
281
+ 'msub',
282
+ 'msup',
283
+ 'msubsup',
284
+ 'munder',
285
+ 'mover',
286
+ 'munderover',
287
+ 'mtable',
288
+ 'mtr',
289
+ 'mtd',
290
+ 'mpadded',
291
+ 'mphantom',
292
+ 'menclose',
207
293
  ])
208
294
 
209
295
  /** Track SVG context depth — children of <svg> inherit the SVG namespace. */
@@ -303,9 +389,17 @@ function mountComponent(
303
389
  let output: VNodeChild
304
390
 
305
391
  const componentName = (vnode.type.name || 'Anonymous') as string
306
- const compId = `${componentName}-${Math.random().toString(36).slice(2, 9)}`
307
- const parentId = _mountingStack[_mountingStack.length - 1] ?? null
308
- _mountingStack.push(compId)
392
+
393
+ // Devtools: generate ID + track parent/child hierarchy (dev only).
394
+ // In production, compId/devParentId are never assigned — Vite tree-shakes
395
+ // all __DEV__ blocks + the devtools module import to zero bytes.
396
+ let compId: string | undefined
397
+ let devParentId: string | null | undefined
398
+ if (__DEV__) {
399
+ compId = `${componentName}-${Math.random().toString(36).slice(2, 9)}`
400
+ devParentId = _mountingStack![_mountingStack!.length - 1] ?? null
401
+ _mountingStack!.push(compId)
402
+ }
309
403
 
310
404
  // Merge vnode.children into props.children if not already set
311
405
  const children = vnode.children ?? []
@@ -320,14 +414,15 @@ function mountComponent(
320
414
  // Convert compiler-emitted () => expr wrappers into getter properties.
321
415
  // This makes component props reactive — reading props.state inside an
322
416
  // effect/computed tracks the underlying signals.
323
- const mergedProps = rawProps === EMPTY_PROPS ? rawProps : makeReactiveProps(rawProps as Record<string, unknown>)
417
+ const mergedProps =
418
+ rawProps === EMPTY_PROPS ? rawProps : makeReactiveProps(rawProps as Record<string, unknown>)
324
419
 
325
420
  try {
326
421
  const result = runWithHooks(vnode.type, mergedProps)
327
422
  hooks = result.hooks
328
423
  output = result.vnode
329
424
  } catch (err) {
330
- _mountingStack.pop()
425
+ if (__DEV__) _mountingStack!.pop()
331
426
  setCurrentScope(null)
332
427
  scope.stop()
333
428
  reportError({
@@ -362,7 +457,7 @@ function mountComponent(
362
457
  'Components must be synchronous — use lazy() + Suspense for async loading, ' +
363
458
  'or fetch data in onMount and store it in a signal.',
364
459
  )
365
- } else if (!('type' in output) && !Array.isArray(output) && !((output as any).__isNative)) {
460
+ } else if (!('type' in output) && !Array.isArray(output) && !(output as any).__isNative) {
366
461
  // Objects without `type` that are NOT arrays (valid VNodeChild[])
367
462
  // and NOT NativeItems (from _tpl()) are invalid. Arrays come from
368
463
  // Fragment returns, NativeItems come from compiled templates.
@@ -372,15 +467,15 @@ function mountComponent(
372
467
  }
373
468
  }
374
469
 
375
- for (const fn of hooks.update) {
376
- scope.addUpdateHook(fn)
470
+ if (hooks.update) {
471
+ for (const fn of hooks.update) scope.addUpdateHook(fn)
377
472
  }
378
473
 
379
474
  let subtreeCleanup: Cleanup = noop
380
475
  try {
381
476
  subtreeCleanup = output != null ? mountChild(output, parent, anchor) : noop
382
477
  } catch (err) {
383
- _mountingStack.pop()
478
+ if (__DEV__) _mountingStack!.pop()
384
479
  scope.stop()
385
480
  const handled = propagateError(err, hooks) || dispatchToErrorBoundary(err)
386
481
  if (!handled) {
@@ -396,44 +491,53 @@ function mountComponent(
396
491
  return noop
397
492
  }
398
493
 
399
- _mountingStack.pop()
400
-
401
- const firstEl = parent instanceof Element ? parent.firstElementChild : null
402
- registerComponent(compId, componentName, firstEl, parentId)
494
+ if (__DEV__) {
495
+ _mountingStack!.pop()
496
+ const firstEl = parent instanceof Element ? parent.firstElementChild : null
497
+ registerComponent(compId!, componentName, firstEl, devParentId!)
498
+ }
403
499
 
404
- // Fire onMount hooks inline — effects created inside are tracked by the scope
405
- const mountCleanups: Cleanup[] = []
406
- for (const fn of hooks.mount) {
407
- try {
408
- let cleanup: (() => void) | undefined
409
- scope.runInScope(() => {
410
- cleanup = fn() as (() => void) | undefined
411
- })
412
- if (cleanup) mountCleanups.push(cleanup)
413
- } catch (err) {
414
- console.error(`[Pyreon] Error in onMount hook of <${componentName}>:`, err)
415
- reportError({ component: componentName, phase: 'mount', error: err, timestamp: Date.now() })
500
+ // Fire onMount hooks inline — effects created inside are tracked by the scope.
501
+ // Lazy-allocate mountCleanups only when an onMount callback returns a cleanup fn.
502
+ let mountCleanups: Cleanup[] | null = null
503
+ if (hooks.mount) {
504
+ for (const fn of hooks.mount) {
505
+ try {
506
+ let cleanup: (() => void) | undefined
507
+ scope.runInScope(() => {
508
+ cleanup = fn() as (() => void) | undefined
509
+ })
510
+ if (cleanup) {
511
+ if (mountCleanups === null) mountCleanups = []
512
+ mountCleanups.push(cleanup)
513
+ }
514
+ } catch (err) {
515
+ console.error(`[Pyreon] Error in onMount hook of <${componentName}>:`, err)
516
+ reportError({ component: componentName, phase: 'mount', error: err, timestamp: Date.now() })
517
+ }
416
518
  }
417
519
  }
418
520
 
419
521
  return () => {
420
- unregisterComponent(compId)
522
+ if (__DEV__) unregisterComponent(compId!)
421
523
  scope.stop()
422
524
  subtreeCleanup()
423
- for (const fn of hooks.unmount) {
424
- try {
425
- fn()
426
- } catch (err) {
427
- console.error(`[Pyreon] Error in onUnmount hook of <${componentName}>:`, err)
428
- reportError({
429
- component: componentName,
430
- phase: 'unmount',
431
- error: err,
432
- timestamp: Date.now(),
433
- })
525
+ if (hooks.unmount) {
526
+ for (const fn of hooks.unmount) {
527
+ try {
528
+ fn()
529
+ } catch (err) {
530
+ console.error(`[Pyreon] Error in onUnmount hook of <${componentName}>:`, err)
531
+ reportError({
532
+ component: componentName,
533
+ phase: 'unmount',
534
+ error: err,
535
+ timestamp: Date.now(),
536
+ })
537
+ }
434
538
  }
435
539
  }
436
- for (const fn of mountCleanups) fn()
540
+ if (mountCleanups) for (const fn of mountCleanups) fn()
437
541
  }
438
542
  }
439
543
 
package/src/nodes.ts CHANGED
@@ -7,8 +7,10 @@ import { effect, runUntracked } from '@pyreon/reactivity'
7
7
 
8
8
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
9
9
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
10
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
11
- const __DEV__ = import.meta.env?.DEV === true
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 }
12
14
 
13
15
  type Cleanup = () => void
14
16
 
@@ -88,9 +90,7 @@ export function mountReactive(
88
90
  // (e.g. DynamicStyled's class swap effect). Those effects track
89
91
  // their own dependencies independently.
90
92
  const cleanup = runUntracked(() =>
91
- restoreContextStack(contextSnapshot, () =>
92
- mount(value, parent, marker),
93
- ),
93
+ restoreContextStack(contextSnapshot, () => mount(value, parent, marker)),
94
94
  )
95
95
  // Guard: a re-entrant signal update (e.g. ErrorBoundary catching a child
96
96
  // throw) may have already re-run this effect and updated currentCleanup.
@@ -157,6 +157,7 @@ function computeKeyedLis(
157
157
  ): number {
158
158
  const { tails, tailIdx, pred } = lis
159
159
  let lisLen = 0
160
+ let ops = 0
160
161
  for (let i = 0; i < n; i++) {
161
162
  const key = newKeyOrder[i]
162
163
  if (key === undefined) continue
@@ -167,6 +168,7 @@ function computeKeyedLis(
167
168
  let hi = lisLen
168
169
  while (lo < hi) {
169
170
  const mid = (lo + hi) >> 1
171
+ ops++
170
172
  if ((tails[mid] as number) < v) lo = mid + 1
171
173
  else hi = mid
172
174
  }
@@ -175,6 +177,7 @@ function computeKeyedLis(
175
177
  if (lo > 0) pred[i] = tailIdx[lo - 1] as number
176
178
  if (lo === lisLen) lisLen++
177
179
  }
180
+ if (__DEV__ && ops > 0) _countSink.__pyreon_count__?.('runtime.mountFor.lisOps', ops)
178
181
  return lisLen
179
182
  }
180
183
 
@@ -379,21 +382,62 @@ function computeForLis(
379
382
  ): number {
380
383
  const { tails, tailIdx, pred } = lis
381
384
  let lisLen = 0
385
+ let ops = 0
386
+ // Two-tier fast path.
387
+ //
388
+ // Tier 1 — "extend LIS": if v > the current tail-of-tails, v becomes the
389
+ // new tail. O(1). Covers APPEND: positions [0..N-1] are strictly
390
+ // increasing → the whole sequence is the LIS, 0 probes.
391
+ //
392
+ // Tier 2 — "known slot": if v ≤ lastV but tails[v] === v already, the
393
+ // binary-search answer is provably lo = v (strict-increase invariant
394
+ // guarantees tails[v-1] < v, so v slots exactly at index v). O(1) too.
395
+ // Covers PREPEND: [new N rows, old M rows] produces positions [0..N-1,
396
+ // 0..M-1] — the second monotonic run replaces tails[0..M-1] at indices
397
+ // N..N+M-1 with zero probes each. Before this tier, 1k prepend was ~10k
398
+ // probes; with it, 0.
399
+ //
400
+ // Tier 3 — binary search fallback. Random shuffles and other mixed
401
+ // reorders pay the standard log₂(lisLen) per index.
402
+ //
403
+ // Safety: the `v < lisLen && tails[v] === v` check is a strict subset of
404
+ // "binary-search would return v", so it never produces a wrong answer on
405
+ // shufles — it just opportunistically avoids probing when the answer
406
+ // happens to be the index itself. No behaviour change, only fewer probes.
407
+ let lastV = -1
382
408
  for (let i = 0; i < n; i++) {
383
409
  const key = newKeys[i] as string | number
384
410
  const v = cache.get(key)?.pos ?? 0
385
- let lo = 0
386
- let hi = lisLen
387
- while (lo < hi) {
388
- const mid = (lo + hi) >> 1
389
- if ((tails[mid] as number) < v) lo = mid + 1
390
- else hi = mid
411
+ // Tier 1: extend LIS.
412
+ if (v > lastV) {
413
+ tails[lisLen] = v
414
+ tailIdx[lisLen] = i
415
+ if (lisLen > 0) pred[i] = tailIdx[lisLen - 1] as number
416
+ lisLen++
417
+ lastV = v
418
+ continue
419
+ }
420
+ // Tier 2: known slot for piecewise-monotonic patterns (prepend, etc.).
421
+ let lo: number
422
+ if (v < lisLen && (tails[v] as number) === v) {
423
+ lo = v
424
+ } else {
425
+ // Tier 3: binary search.
426
+ lo = 0
427
+ let hi = lisLen
428
+ while (lo < hi) {
429
+ const mid = (lo + hi) >> 1
430
+ ops++
431
+ if ((tails[mid] as number) < v) lo = mid + 1
432
+ else hi = mid
433
+ }
391
434
  }
392
435
  tails[lo] = v
393
436
  tailIdx[lo] = i
394
437
  if (lo > 0) pred[i] = tailIdx[lo - 1] as number
395
- if (lo === lisLen) lisLen++
438
+ // v ≤ lastV here, so tails can't be extended: lo < lisLen always.
396
439
  }
440
+ if (__DEV__ && ops > 0) _countSink.__pyreon_count__?.('runtime.mountFor.lisOps', ops)
397
441
  return lisLen
398
442
  }
399
443
 
@@ -463,6 +507,7 @@ export function mountFor<T>(
463
507
 
464
508
  let cache = new Map<string | number, ForEntry>()
465
509
  let currentKeys: (string | number)[] = []
510
+ const _reusableKeySet = new Set<string | number>()
466
511
  let cleanupCount = 0
467
512
  let anchorsRegistered = false
468
513
 
@@ -644,7 +689,11 @@ export function mountFor<T>(
644
689
  newKeys: (string | number)[],
645
690
  liveParent: Node,
646
691
  ) => {
647
- removeStaleForEntries(new Set<string | number>(newKeys))
692
+ // Reuse a persistent Set to avoid allocating a new one per update.
693
+ // Cleared + repopulated instead of constructing new Set(newKeys).
694
+ _reusableKeySet.clear()
695
+ for (let i = 0; i < newKeys.length; i++) _reusableKeySet.add(newKeys[i] as string | number)
696
+ removeStaleForEntries(_reusableKeySet)
648
697
  mountNewForEntries(items, n, newKeys, liveParent)
649
698
 
650
699
  if (!anchorsRegistered) {
package/src/props.ts CHANGED
@@ -8,8 +8,7 @@ type Cleanup = () => void
8
8
 
9
9
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
10
10
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
11
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
12
- const __DEV__ = import.meta.env?.DEV === true
11
+ const __DEV__ = process.env.NODE_ENV !== 'production'
13
12
 
14
13
  // ─── Configurable sanitizer ──────────────────────────────────────────────────
15
14
 
package/src/template.ts CHANGED
@@ -2,6 +2,13 @@ import type { NativeItem, VNodeChild } from '@pyreon/core'
2
2
  import { renderEffect } from '@pyreon/reactivity'
3
3
  import { mountChild } from './mount'
4
4
 
5
+ // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
6
+ // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
7
+ const __DEV__ = process.env.NODE_ENV !== 'production'
8
+
9
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
10
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
11
+
5
12
  /**
6
13
  * Creates a row/item factory backed by HTML template cloning.
7
14
  *
@@ -68,7 +75,8 @@ export function _bindText(
68
75
  if (source.direct) {
69
76
  const textUpdate = () => {
70
77
  const v = source._v
71
- node.data = v == null || v === false ? '' : String(v as string | number)
78
+ const next = v == null || v === false ? '' : String(v as string | number)
79
+ if (next !== node.data) node.data = next
72
80
  }
73
81
  textUpdate()
74
82
  return source.direct(textUpdate)
@@ -77,7 +85,8 @@ export function _bindText(
77
85
  const fn = source as unknown as () => unknown
78
86
  return renderEffect(() => {
79
87
  const v = fn()
80
- node.data = v == null || v === false ? '' : String(v as string | number)
88
+ const next = v == null || v === false ? '' : String(v as string | number)
89
+ if (next !== node.data) node.data = next
81
90
  })
82
91
  }
83
92
 
@@ -116,6 +125,22 @@ export function _bindDirect(
116
125
  // ─── Compiler-facing template API ─────────────────────────────────────────────
117
126
 
118
127
  // Cache parsed <template> elements by HTML string — parse once, clone many.
128
+ //
129
+ // LRU bound (audit bug #5): typical apps emit a small bounded set of unique
130
+ // HTML strings (one per JSX element tree the compiler hoists), so the cache
131
+ // stays in the dozens-to-hundreds in practice. But an app that constructs
132
+ // JSX from user input (or compiles many large dynamic templates) could grow
133
+ // this unbounded — every unique string holds a parsed <template> alive.
134
+ //
135
+ // Map preserves insertion order; on overflow we evict the OLDEST entry (the
136
+ // least-recently-inserted). Common HTML strings hit the cache before
137
+ // eviction; pathological inputs cycle through the cap without leaking.
138
+ //
139
+ // 1024 chosen as a balance: ~1024 unique templates × ~1KB parsed = ~1MB
140
+ // worst case — well within memory budget for any realistic app, and
141
+ // generous enough that no real codebase will hit the cap. Apps that
142
+ // genuinely need a different cap can swap their own _tpl wrapper.
143
+ const TPL_CACHE_MAX = 1024
119
144
  const _tplCache = new Map<string, HTMLTemplateElement>()
120
145
 
121
146
  /**
@@ -142,10 +167,23 @@ const _tplCache = new Map<string, HTMLTemplateElement>()
142
167
  * })
143
168
  */
144
169
  export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | null): NativeItem {
170
+ if (__DEV__) _countSink.__pyreon_count__?.('runtime.tpl')
145
171
  let tpl = _tplCache.get(html)
146
172
  if (!tpl) {
147
173
  tpl = document.createElement('template')
148
174
  tpl.innerHTML = html
175
+ // LRU eviction — drop the oldest entry once we hit the cap. Map
176
+ // iteration is insertion-order so the first key is always the
177
+ // oldest. delete() is O(1).
178
+ if (_tplCache.size >= TPL_CACHE_MAX) {
179
+ const oldest = _tplCache.keys().next().value
180
+ if (oldest !== undefined) _tplCache.delete(oldest)
181
+ }
182
+ _tplCache.set(html, tpl)
183
+ } else {
184
+ // LRU touch — re-insert moves to most-recent position so frequently
185
+ // used templates survive eviction.
186
+ _tplCache.delete(html)
149
187
  _tplCache.set(html, tpl)
150
188
  }
151
189
  const el = tpl.content.firstElementChild?.cloneNode(true) as HTMLElement
@@ -153,6 +191,23 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
153
191
  return { __isNative: true, el, cleanup }
154
192
  }
155
193
 
194
+ /**
195
+ * Test-only: clear the template cache. Used by tests that assert on
196
+ * cache size; never called by runtime code. Not exported from the
197
+ * package's public index.
198
+ */
199
+ export function _clearTplCache(): void {
200
+ _tplCache.clear()
201
+ }
202
+
203
+ /**
204
+ * Test-only: read current cache size. Used by tests that assert
205
+ * eviction. Not exported from the package's public index.
206
+ */
207
+ export function _tplCacheSize(): number {
208
+ return _tplCache.size
209
+ }
210
+
156
211
  /**
157
212
  * Mount a children slot inside a template.
158
213
  *