@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/README.md +23 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +5406 -0
- package/lib/analysis/transition-entry.js.html +5406 -0
- package/lib/index.js +156 -57
- package/lib/keep-alive-entry.js +1342 -0
- package/lib/transition-entry.js +168 -0
- package/lib/types/index.d.ts +54 -5
- package/lib/types/keep-alive-entry.d.ts +41 -0
- package/lib/types/transition-entry.d.ts +59 -0
- package/package.json +17 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +23 -14
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +30 -6
- package/src/keep-alive-entry.ts +3 -0
- package/src/keep-alive.ts +5 -1
- package/src/mount.ts +160 -56
- package/src/nodes.ts +62 -13
- package/src/props.ts +1 -2
- package/src/template.ts +57 -2
- package/src/tests/coverage-gaps.test.ts +709 -0
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/lis-prepend.browser.test.ts +99 -0
- package/src/tests/mount.test.ts +91 -0
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +121 -7
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +135 -1
- package/src/transition-entry.ts +7 -0
- package/src/transition-group.ts +6 -1
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
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 {
|
|
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 {
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
59
|
+
if (__DEV__) {
|
|
60
|
+
_countSink.__pyreon_count__?.('runtime.mount')
|
|
61
|
+
installDevTools()
|
|
62
|
+
}
|
|
43
63
|
setupDelegation(container)
|
|
44
64
|
container.innerHTML = ''
|
|
45
|
-
|
|
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` */
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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',
|
|
193
|
-
'
|
|
194
|
-
'
|
|
195
|
-
'
|
|
196
|
-
'
|
|
197
|
-
'
|
|
198
|
-
'
|
|
199
|
-
'
|
|
200
|
-
'
|
|
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',
|
|
205
|
-
'
|
|
206
|
-
'
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
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
|
|
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) && !(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
cleanup
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|