@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/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
- // @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'
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
- if (n === 0 && cache.size > 0) {
299
- for (const entry of cache.values()) entry.cleanup()
300
- cache.clear()
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
- if (currentKeyOrder.length > 0 && n > 0) {
312
- lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker)
313
- }
330
+ if (currentKeyOrder.length > 0 && n > 0) {
331
+ lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker)
332
+ }
314
333
 
315
- curPos.clear()
316
- for (let i = 0; i < newKeyOrder.length; i++) {
317
- const k = newKeyOrder[i]
318
- if (k !== undefined) curPos.set(k, i)
319
- }
320
- currentKeyOrder = newKeyOrder
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()) if (entry.cleanup) entry.cleanup()
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()) if (entry.cleanup) entry.cleanup()
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
- if (n === 0) {
721
- handleFastClear(liveParent)
722
- return
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
- const newKeys = collectNewKeys(items, n)
772
+ const newKeys = collectNewKeys(items, n)
731
773
 
732
- if (!hasAnyKeptKey(n, newKeys)) {
733
- handleReplaceAll(items, n, newKeys, liveParent)
734
- return
735
- }
774
+ if (!hasAnyKeptKey(n, newKeys)) {
775
+ handleReplaceAll(items, n, newKeys, liveParent)
776
+ return
777
+ }
736
778
 
737
- handleIncrementalUpdate(items, n, newKeys, liveParent)
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) 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
- // @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'
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
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
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 pattern that
12
- // Vite/Rolldown can literal-replace at build time, NOT the broken
13
- // `typeof process` pattern that PR #200 cleaned up.
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` (introduced in PR #220) is the
18
- // CI-wide enforcement for this. This test is the narrow, package-local
19
- // safety net so a regression in runtime-dom is caught even if the lint
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 import.meta.env.DEV, not typeof process`, async () => {
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 constant must exist, defined via Vite's literal-replaced env.
35
- expect(code).toMatch(/const\s+__DEV__\s*=\s*import\.meta\.env\??\.DEV/)
36
- // The broken pattern must not appear anywhere in executable code.
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 T1.1 C-2 finding.
11
+ // Bundle-level regression test for the dev-warning gate.
12
12
  //
13
- // Background the shape of the problem from PR #227 bring-up:
14
- // Raw `esbuild --minify` preserves chained `__DEV__ && cond &&
15
- // console.warn(...)` patterns even when `import.meta.env.DEV` is
16
- // defined to `false`. That tempted a pattern-rewrite across all
17
- // Pyreon sources.
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
- // What the C-2 investigation actually found:
20
- // Pyreon's real consumer path is Vite (which uses Rolldown under the
21
- // hood plus its own import.meta.env replacement + tree-shake passes).
22
- // Vite's production build DOES eliminate the chained patterns
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
- // This test bundles a runtime-dom entry through Vite's production
28
- // build and asserts dev-warning strings are GONE. If Vite's handling
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. `define` on
88
- // `import.meta.env` isn't usually needed (Vite sets it automatically
89
- // based on mode), but `mode: 'production'` flips DEV to false.
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
- 'import.meta.env.DEV': JSON.stringify(dev),
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