@pyreon/runtime-dom 0.14.0 → 0.16.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/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +1 -1
- package/lib/analysis/transition-entry.js.html +1 -1
- package/lib/index.js +150 -62
- package/lib/keep-alive-entry.js +81 -44
- package/lib/transition-entry.js +3 -2
- package/lib/types/index.d.ts +54 -5
- package/package.json +7 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +9 -2
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +11 -3
- package/src/keep-alive.ts +15 -4
- package/src/mount.ts +1 -2
- package/src/nodes.ts +87 -41
- package/src/props.ts +11 -2
- package/src/template.ts +48 -2
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/fanout-repro.test.tsx +219 -0
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/mount.test.ts +92 -1
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +58 -6
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +71 -1
- package/src/tests/transition.test.ts +5 -1
- package/src/transition-group.ts +22 -7
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/keep-alive-entry.js.map +0 -1
- package/lib/transition-entry.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/keep-alive-entry.d.ts.map +0 -1
- package/lib/types/transition-entry.d.ts.map +0 -1
package/src/nodes.ts
CHANGED
|
@@ -7,8 +7,7 @@ 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
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
10
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
12
11
|
|
|
13
12
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
14
13
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
@@ -38,6 +37,11 @@ function clearBetween(start: Node, end: Node): void {
|
|
|
38
37
|
// frag goes out of scope → nodes are GC-eligible
|
|
39
38
|
}
|
|
40
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
|
+
|
|
41
45
|
/**
|
|
42
46
|
* Mount a reactive node whose content changes over time.
|
|
43
47
|
*
|
|
@@ -50,6 +54,7 @@ export function mountReactive(
|
|
|
50
54
|
anchor: Node | null,
|
|
51
55
|
mount: (child: VNodeChild, p: Node, a: Node | null) => Cleanup,
|
|
52
56
|
): Cleanup {
|
|
57
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.mountReactive')
|
|
53
58
|
const marker = document.createComment('pyreon')
|
|
54
59
|
parent.insertBefore(marker, anchor)
|
|
55
60
|
|
|
@@ -62,6 +67,9 @@ export function mountReactive(
|
|
|
62
67
|
let currentCleanup: Cleanup = () => {
|
|
63
68
|
/* noop */
|
|
64
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
|
|
65
73
|
let generation = 0
|
|
66
74
|
|
|
67
75
|
const e = effect(() => {
|
|
@@ -69,10 +77,12 @@ export function mountReactive(
|
|
|
69
77
|
// Run cleanup outside tracking context — cleanup may write to signals
|
|
70
78
|
// (e.g. onUnmount hooks), and those writes must not accidentally register
|
|
71
79
|
// as dependencies of this effect, which would cause infinite recursion.
|
|
80
|
+
if (hasCleanup) _emitCleanup()
|
|
72
81
|
runUntracked(() => currentCleanup())
|
|
73
82
|
currentCleanup = () => {
|
|
74
83
|
/* noop */
|
|
75
84
|
}
|
|
85
|
+
hasCleanup = false
|
|
76
86
|
const value = accessor()
|
|
77
87
|
// Note: typeof value === 'function' is a VALID return from a reactive
|
|
78
88
|
// accessor — it represents a nested `() => VNodeChild` accessor (the
|
|
@@ -99,7 +109,9 @@ export function mountReactive(
|
|
|
99
109
|
// set by the re-entrant run.
|
|
100
110
|
if (myGen === generation) {
|
|
101
111
|
currentCleanup = cleanup
|
|
112
|
+
hasCleanup = true
|
|
102
113
|
} else {
|
|
114
|
+
_emitCleanup()
|
|
103
115
|
cleanup()
|
|
104
116
|
}
|
|
105
117
|
}
|
|
@@ -107,6 +119,7 @@ export function mountReactive(
|
|
|
107
119
|
|
|
108
120
|
return () => {
|
|
109
121
|
e.dispose()
|
|
122
|
+
if (hasCleanup) _emitCleanup()
|
|
110
123
|
currentCleanup()
|
|
111
124
|
marker.parentNode?.removeChild(marker)
|
|
112
125
|
}
|
|
@@ -271,6 +284,7 @@ export function mountKeyedList(
|
|
|
271
284
|
const removeStaleEntries = (newKeySet: Set<string | number>) => {
|
|
272
285
|
for (const [key, entry] of cache) {
|
|
273
286
|
if (newKeySet.has(key)) continue
|
|
287
|
+
_emitCleanup()
|
|
274
288
|
entry.cleanup()
|
|
275
289
|
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
276
290
|
cache.delete(key)
|
|
@@ -294,35 +308,42 @@ export function mountKeyedList(
|
|
|
294
308
|
const e = effect(() => {
|
|
295
309
|
const newList = accessor()
|
|
296
310
|
const n = newList.length
|
|
311
|
+
// Same untracking rationale as mountFor — see comment there. Child
|
|
312
|
+
// mounts via mountVNode must not re-track on this effect's run.
|
|
313
|
+
runUntracked(() => {
|
|
314
|
+
if (n === 0 && cache.size > 0) {
|
|
315
|
+
for (const entry of cache.values()) {
|
|
316
|
+
_emitCleanup()
|
|
317
|
+
entry.cleanup()
|
|
318
|
+
}
|
|
319
|
+
cache.clear()
|
|
320
|
+
curPos.clear()
|
|
321
|
+
currentKeyOrder = []
|
|
322
|
+
clearBetween(startMarker, tailMarker)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
297
325
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
curPos.clear()
|
|
302
|
-
currentKeyOrder = []
|
|
303
|
-
clearBetween(startMarker, tailMarker)
|
|
304
|
-
return
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
|
|
308
|
-
removeStaleEntries(newKeySet)
|
|
309
|
-
mountNewEntries(newList)
|
|
326
|
+
const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
|
|
327
|
+
removeStaleEntries(newKeySet)
|
|
328
|
+
mountNewEntries(newList)
|
|
310
329
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
330
|
+
if (currentKeyOrder.length > 0 && n > 0) {
|
|
331
|
+
lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker)
|
|
332
|
+
}
|
|
314
333
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
334
|
+
curPos.clear()
|
|
335
|
+
for (let i = 0; i < newKeyOrder.length; i++) {
|
|
336
|
+
const k = newKeyOrder[i]
|
|
337
|
+
if (k !== undefined) curPos.set(k, i)
|
|
338
|
+
}
|
|
339
|
+
currentKeyOrder = newKeyOrder
|
|
340
|
+
})
|
|
321
341
|
})
|
|
322
342
|
|
|
323
343
|
return () => {
|
|
324
344
|
e.dispose()
|
|
325
345
|
for (const entry of cache.values()) {
|
|
346
|
+
_emitCleanup()
|
|
326
347
|
entry.cleanup()
|
|
327
348
|
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
328
349
|
}
|
|
@@ -603,7 +624,12 @@ export function mountFor<T>(
|
|
|
603
624
|
liveParent: Node,
|
|
604
625
|
) => {
|
|
605
626
|
if (cleanupCount > 0) {
|
|
606
|
-
for (const entry of cache.values())
|
|
627
|
+
for (const entry of cache.values()) {
|
|
628
|
+
if (entry.cleanup) {
|
|
629
|
+
_emitCleanup()
|
|
630
|
+
entry.cleanup()
|
|
631
|
+
}
|
|
632
|
+
}
|
|
607
633
|
}
|
|
608
634
|
cache = new Map()
|
|
609
635
|
cleanupCount = 0
|
|
@@ -635,6 +661,7 @@ export function mountFor<T>(
|
|
|
635
661
|
for (const [key, entry] of cache) {
|
|
636
662
|
if (newKeySet.has(key)) continue
|
|
637
663
|
if (entry.cleanup) {
|
|
664
|
+
_emitCleanup()
|
|
638
665
|
entry.cleanup()
|
|
639
666
|
cleanupCount--
|
|
640
667
|
}
|
|
@@ -661,7 +688,12 @@ export function mountFor<T>(
|
|
|
661
688
|
const handleFastClear = (liveParent: Node) => {
|
|
662
689
|
if (cache.size === 0) return
|
|
663
690
|
if (cleanupCount > 0) {
|
|
664
|
-
for (const entry of cache.values())
|
|
691
|
+
for (const entry of cache.values()) {
|
|
692
|
+
if (entry.cleanup) {
|
|
693
|
+
_emitCleanup()
|
|
694
|
+
entry.cleanup()
|
|
695
|
+
}
|
|
696
|
+
}
|
|
665
697
|
}
|
|
666
698
|
const pp = liveParent.parentNode
|
|
667
699
|
if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
|
|
@@ -716,31 +748,45 @@ export function mountFor<T>(
|
|
|
716
748
|
if (!liveParent) return
|
|
717
749
|
const items = source()
|
|
718
750
|
const n = items.length
|
|
751
|
+
// Child mounts (renderInto → mountChild) must NOT re-track on this
|
|
752
|
+
// effect's run, mirroring mountReactive's pattern at line ~92. Without
|
|
753
|
+
// this, any signal read during a child component's setup (e.g. useQuery
|
|
754
|
+
// calling `new QueryObserver(client, options())` at construction time,
|
|
755
|
+
// which reads any signals inside the options builder) leaks its
|
|
756
|
+
// subscription up to the For effect. A flip of the unrelated signal
|
|
757
|
+
// re-runs For, runCleanup() disposes ALL inner effects, and
|
|
758
|
+
// handleIncrementalUpdate skips re-mount on key match — leaving the
|
|
759
|
+
// subtree's inner effects gone forever. Reproduced by the
|
|
760
|
+
// `<For>`-shaped test in fanout-repro.test.tsx; deferred from PR #490.
|
|
761
|
+
runUntracked(() => {
|
|
762
|
+
if (n === 0) {
|
|
763
|
+
handleFastClear(liveParent)
|
|
764
|
+
return
|
|
765
|
+
}
|
|
719
766
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (currentKeys.length === 0) {
|
|
726
|
-
handleFreshRender(items, n, liveParent)
|
|
727
|
-
return
|
|
728
|
-
}
|
|
767
|
+
if (currentKeys.length === 0) {
|
|
768
|
+
handleFreshRender(items, n, liveParent)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
729
771
|
|
|
730
|
-
|
|
772
|
+
const newKeys = collectNewKeys(items, n)
|
|
731
773
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
774
|
+
if (!hasAnyKeptKey(n, newKeys)) {
|
|
775
|
+
handleReplaceAll(items, n, newKeys, liveParent)
|
|
776
|
+
return
|
|
777
|
+
}
|
|
736
778
|
|
|
737
|
-
|
|
779
|
+
handleIncrementalUpdate(items, n, newKeys, liveParent)
|
|
780
|
+
})
|
|
738
781
|
})
|
|
739
782
|
|
|
740
783
|
return () => {
|
|
741
784
|
e.dispose()
|
|
742
785
|
for (const entry of cache.values()) {
|
|
743
|
-
if (cleanupCount > 0 && entry.cleanup)
|
|
786
|
+
if (cleanupCount > 0 && entry.cleanup) {
|
|
787
|
+
_emitCleanup()
|
|
788
|
+
entry.cleanup()
|
|
789
|
+
}
|
|
744
790
|
entry.anchor.parentNode?.removeChild(entry.anchor)
|
|
745
791
|
}
|
|
746
792
|
cache = new Map()
|
package/src/props.ts
CHANGED
|
@@ -8,8 +8,10 @@ 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
|
-
|
|
11
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
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 }
|
|
13
15
|
|
|
14
16
|
// ─── Configurable sanitizer ──────────────────────────────────────────────────
|
|
15
17
|
|
|
@@ -206,6 +208,7 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
|
|
|
206
208
|
* Bind an event handler (onClick → "click") with batching + delegation support.
|
|
207
209
|
*/
|
|
208
210
|
function applyEventProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
211
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.applyEvent')
|
|
209
212
|
if (typeof value !== 'function') {
|
|
210
213
|
// `undefined` and `null` are legitimate — conditional handler pattern:
|
|
211
214
|
// <button onClick={condition ? handler : undefined}>
|
|
@@ -296,7 +299,13 @@ function applyStaticProp(el: Element, key: string, value: unknown): void {
|
|
|
296
299
|
setStaticProp(el, key, value)
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
// `runtime.applyProp` fires for EVERY prop key, including events. `runtime.applyEvent`
|
|
303
|
+
// fires only for `on*` props — strict subset. Useful diagnostic ratios:
|
|
304
|
+
// applyEvent / applyProp = event-handler density per element
|
|
305
|
+
// applyProp - applyEvent = static / reactive attr density
|
|
306
|
+
// Don't subtract them and treat as disjoint.
|
|
299
307
|
export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
308
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.applyProp')
|
|
300
309
|
// Event listener: onClick → "click"
|
|
301
310
|
if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
|
|
302
311
|
|
package/src/template.ts
CHANGED
|
@@ -4,8 +4,7 @@ import { mountChild } from './mount'
|
|
|
4
4
|
|
|
5
5
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
6
6
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
7
|
-
|
|
8
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
7
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
9
8
|
|
|
10
9
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
11
10
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
@@ -72,6 +71,7 @@ export function _bindText(
|
|
|
72
71
|
source: { _v?: unknown; direct?: (fn: () => void) => () => void },
|
|
73
72
|
node: Text,
|
|
74
73
|
): () => void {
|
|
74
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.bindText')
|
|
75
75
|
// Fast path: source has .direct() (signal or computed)
|
|
76
76
|
if (source.direct) {
|
|
77
77
|
const textUpdate = () => {
|
|
@@ -113,6 +113,7 @@ export function _bindDirect(
|
|
|
113
113
|
source: { _v?: unknown; direct?: (fn: () => void) => () => void },
|
|
114
114
|
updater: (value: unknown) => void,
|
|
115
115
|
): () => void {
|
|
116
|
+
if (__DEV__) _countSink.__pyreon_count__?.('runtime.bindDirect')
|
|
116
117
|
// Fast path: source has .direct() (signal or computed)
|
|
117
118
|
if (source.direct) {
|
|
118
119
|
updater(source._v)
|
|
@@ -126,6 +127,22 @@ export function _bindDirect(
|
|
|
126
127
|
// ─── Compiler-facing template API ─────────────────────────────────────────────
|
|
127
128
|
|
|
128
129
|
// Cache parsed <template> elements by HTML string — parse once, clone many.
|
|
130
|
+
//
|
|
131
|
+
// LRU bound (audit bug #5): typical apps emit a small bounded set of unique
|
|
132
|
+
// HTML strings (one per JSX element tree the compiler hoists), so the cache
|
|
133
|
+
// stays in the dozens-to-hundreds in practice. But an app that constructs
|
|
134
|
+
// JSX from user input (or compiles many large dynamic templates) could grow
|
|
135
|
+
// this unbounded — every unique string holds a parsed <template> alive.
|
|
136
|
+
//
|
|
137
|
+
// Map preserves insertion order; on overflow we evict the OLDEST entry (the
|
|
138
|
+
// least-recently-inserted). Common HTML strings hit the cache before
|
|
139
|
+
// eviction; pathological inputs cycle through the cap without leaking.
|
|
140
|
+
//
|
|
141
|
+
// 1024 chosen as a balance: ~1024 unique templates × ~1KB parsed = ~1MB
|
|
142
|
+
// worst case — well within memory budget for any realistic app, and
|
|
143
|
+
// generous enough that no real codebase will hit the cap. Apps that
|
|
144
|
+
// genuinely need a different cap can swap their own _tpl wrapper.
|
|
145
|
+
const TPL_CACHE_MAX = 1024
|
|
129
146
|
const _tplCache = new Map<string, HTMLTemplateElement>()
|
|
130
147
|
|
|
131
148
|
/**
|
|
@@ -157,6 +174,18 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
|
|
|
157
174
|
if (!tpl) {
|
|
158
175
|
tpl = document.createElement('template')
|
|
159
176
|
tpl.innerHTML = html
|
|
177
|
+
// LRU eviction — drop the oldest entry once we hit the cap. Map
|
|
178
|
+
// iteration is insertion-order so the first key is always the
|
|
179
|
+
// oldest. delete() is O(1).
|
|
180
|
+
if (_tplCache.size >= TPL_CACHE_MAX) {
|
|
181
|
+
const oldest = _tplCache.keys().next().value
|
|
182
|
+
if (oldest !== undefined) _tplCache.delete(oldest)
|
|
183
|
+
}
|
|
184
|
+
_tplCache.set(html, tpl)
|
|
185
|
+
} else {
|
|
186
|
+
// LRU touch — re-insert moves to most-recent position so frequently
|
|
187
|
+
// used templates survive eviction.
|
|
188
|
+
_tplCache.delete(html)
|
|
160
189
|
_tplCache.set(html, tpl)
|
|
161
190
|
}
|
|
162
191
|
const el = tpl.content.firstElementChild?.cloneNode(true) as HTMLElement
|
|
@@ -164,6 +193,23 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
|
|
|
164
193
|
return { __isNative: true, el, cleanup }
|
|
165
194
|
}
|
|
166
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Test-only: clear the template cache. Used by tests that assert on
|
|
198
|
+
* cache size; never called by runtime code. Not exported from the
|
|
199
|
+
* package's public index.
|
|
200
|
+
*/
|
|
201
|
+
export function _clearTplCache(): void {
|
|
202
|
+
_tplCache.clear()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Test-only: read current cache size. Used by tests that assert
|
|
207
|
+
* eviction. Not exported from the package's public index.
|
|
208
|
+
*/
|
|
209
|
+
export function _tplCacheSize(): number {
|
|
210
|
+
return _tplCache.size
|
|
211
|
+
}
|
|
212
|
+
|
|
167
213
|
/**
|
|
168
214
|
* Mount a children slot inside a template.
|
|
169
215
|
*
|
|
@@ -8,22 +8,27 @@ const SRC = path.resolve(here, '..')
|
|
|
8
8
|
|
|
9
9
|
// Source-pattern regression test for the dev-mode warning gate. Pairs with
|
|
10
10
|
// the browser test in `runtime-dom.browser.test.ts` (which proves the gate
|
|
11
|
-
// fires in dev) — this asserts the gate is written using the
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// fires in dev) — this asserts the gate is written using the bundler-agnostic
|
|
12
|
+
// pattern (`process.env.NODE_ENV !== 'production'`) that every modern bundler
|
|
13
|
+
// (Vite, Webpack/Next.js, esbuild, Rollup, Parcel, Bun) literal-replaces at
|
|
14
|
+
// consumer build time. The two previously-shipped broken patterns must not
|
|
15
|
+
// appear:
|
|
16
|
+
// 1. `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
|
|
17
|
+
// — dead in Vite browser bundles.
|
|
18
|
+
// 2. `import.meta.env.DEV` — Vite/Rolldown-only; undefined and silent in
|
|
19
|
+
// Webpack/Next.js, esbuild, Rollup, Parcel, Bun.
|
|
14
20
|
//
|
|
15
21
|
// Same shape as `flow/src/tests/integration.test.ts:warnIgnoredOptions`.
|
|
16
22
|
//
|
|
17
|
-
// The lint rule `pyreon/no-process-dev-gate`
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// configuration drifts.
|
|
23
|
+
// The lint rule `pyreon/no-process-dev-gate` is the CI-wide enforcement for
|
|
24
|
+
// this. This test is the narrow, package-local safety net so a regression in
|
|
25
|
+
// runtime-dom is caught even if the lint configuration drifts.
|
|
21
26
|
|
|
22
27
|
const FILES_WITH_DEV_GATE = ['nodes.ts', 'hydration-debug.ts']
|
|
23
28
|
|
|
24
29
|
describe('runtime-dom dev-warning gate (source pattern)', () => {
|
|
25
30
|
for (const file of FILES_WITH_DEV_GATE) {
|
|
26
|
-
it(`${file} uses
|
|
31
|
+
it(`${file} uses bundler-agnostic process.env.NODE_ENV`, async () => {
|
|
27
32
|
const source = await readFile(path.join(SRC, file), 'utf8')
|
|
28
33
|
// Strip line + block comments so referencing the broken pattern in
|
|
29
34
|
// documentation doesn't false-positive.
|
|
@@ -31,10 +36,11 @@ describe('runtime-dom dev-warning gate (source pattern)', () => {
|
|
|
31
36
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
32
37
|
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
33
38
|
|
|
34
|
-
// The gate
|
|
35
|
-
expect(code).toMatch(/
|
|
36
|
-
//
|
|
39
|
+
// The bundler-agnostic gate must appear (bare `process.env.NODE_ENV`).
|
|
40
|
+
expect(code).toMatch(/process\.env\.NODE_ENV/)
|
|
41
|
+
// Neither broken pattern may appear in executable code.
|
|
37
42
|
expect(code).not.toMatch(/typeof\s+process\s*!==?\s*['"]undefined['"]/)
|
|
43
|
+
expect(code).not.toMatch(/import\.meta\.env\??\.DEV/)
|
|
38
44
|
})
|
|
39
45
|
}
|
|
40
46
|
})
|
|
@@ -8,28 +8,24 @@ import { build } from 'vite'
|
|
|
8
8
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
9
9
|
const SRC = path.resolve(here, '..')
|
|
10
10
|
|
|
11
|
-
// Bundle-level regression test for the
|
|
11
|
+
// Bundle-level regression test for the dev-warning gate.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// runtime-dom uses bundler-agnostic `process.env.NODE_ENV !== 'production'`
|
|
14
|
+
// for dev gates — the cross-bundler library convention used by React, Vue,
|
|
15
|
+
// Preact, Solid, MobX, Redux. Every modern bundler (Vite, Webpack/Next.js,
|
|
16
|
+
// esbuild, Rollup, Parcel, Bun) auto-replaces `process.env.NODE_ENV` at
|
|
17
|
+
// consumer build time. This test bundles each representative runtime-dom
|
|
18
|
+
// file through Vite's production build and asserts dev-warning strings
|
|
19
|
+
// are GONE from the output — proving literal-replacement + dead-code
|
|
20
|
+
// elimination work end-to-end.
|
|
18
21
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// correctly — the raw esbuild baseline was misleading. Raw Rolldown
|
|
24
|
-
// alone also doesn't replicate Vite's behavior because Rolldown's
|
|
25
|
-
// `define` doesn't rewrite optional-chain access paths.
|
|
22
|
+
// The test uses Vite because that's Pyreon's reference consumer pipeline
|
|
23
|
+
// today; the same files under Webpack / esbuild / Rollup etc. tree-shake
|
|
24
|
+
// equivalently because they all replace `process.env.NODE_ENV`. Vite is
|
|
25
|
+
// just the most-tested path.
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// ever regresses, this catches it.
|
|
30
|
-
//
|
|
31
|
-
// Scope note: the existing `dev-gate-pattern.test.ts` is the cheap
|
|
32
|
-
// source-level guard (grep for `typeof process`, require `import.meta.env.DEV`).
|
|
27
|
+
// Scope note: `dev-gate-pattern.test.ts` is the cheap source-level guard
|
|
28
|
+
// (grep for the broken patterns, require bare `process.env.NODE_ENV`).
|
|
33
29
|
// This test is the expensive end-to-end guard for the bundle path.
|
|
34
30
|
|
|
35
31
|
interface FileContract {
|
|
@@ -84,19 +80,17 @@ const FILES_UNDER_TEST: FileContract[] = [
|
|
|
84
80
|
async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
|
|
85
81
|
const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
|
|
86
82
|
try {
|
|
87
|
-
// Vite library-mode build with explicit minify.
|
|
88
|
-
// `
|
|
89
|
-
//
|
|
83
|
+
// Vite library-mode build with explicit minify. The bundler-agnostic
|
|
84
|
+
// gate uses `process.env.NODE_ENV` — Vite's library mode doesn't apply
|
|
85
|
+
// the default replacement automatically, so we set it ourselves to
|
|
86
|
+
// match what every modern bundler does at consumer build time.
|
|
90
87
|
await build({
|
|
91
88
|
mode: dev ? 'development' : 'production',
|
|
92
89
|
logLevel: 'error',
|
|
93
90
|
configFile: false,
|
|
94
91
|
resolve: { conditions: ['bun'] },
|
|
95
|
-
// Explicit define — Vite in lib mode doesn't always apply the
|
|
96
|
-
// default production env replacement, so we set it ourselves.
|
|
97
92
|
define: {
|
|
98
|
-
'
|
|
99
|
-
'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
|
|
93
|
+
'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'),
|
|
100
94
|
},
|
|
101
95
|
build: {
|
|
102
96
|
// PINNED minifier: 'esbuild' is what Pyreon's reference consumers
|