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