@pyreon/runtime-dom 0.12.13 → 0.12.15

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.
@@ -0,0 +1,262 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { build } from 'vite'
7
+
8
+ const here = path.dirname(fileURLToPath(import.meta.url))
9
+ const SRC = path.resolve(here, '..')
10
+
11
+ // Bundle-level regression test for the T1.1 C-2 finding.
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.
18
+ //
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.
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`).
33
+ // This test is the expensive end-to-end guard for the bundle path.
34
+
35
+ interface FileContract {
36
+ file: string
37
+ /** Dev-warning strings that MUST be eliminated from the prod bundle. */
38
+ devWarningStrings: string[]
39
+ }
40
+
41
+ // Coverage strategy: pick representative files across the runtime-dom
42
+ // dev-gate landscape so a regression in any of the typical patterns is
43
+ // caught. `nodes.ts` covers the chained `&&` form (the original
44
+ // problem). `mount.ts` covers the simple `if (__DEV__)` form across
45
+ // multiple Portal/VNode call sites. `props.ts` covers attribute-validation
46
+ // warnings inside small inline `if (__DEV__) { ... }` blocks.
47
+ // `transition.ts` covers a single `if (__DEV__) { console.warn() }`.
48
+ //
49
+ // These four files exercise every shape of dev gate currently used in
50
+ // runtime-dom; if the contract holds for all of them, it holds for the
51
+ // rest of the file set.
52
+ const FILES_UNDER_TEST: FileContract[] = [
53
+ {
54
+ file: 'nodes.ts',
55
+ devWarningStrings: [
56
+ '[Pyreon] <For> `by` function returned null/undefined',
57
+ '[Pyreon] Duplicate key',
58
+ ],
59
+ },
60
+ {
61
+ file: 'mount.ts',
62
+ devWarningStrings: [
63
+ '[Pyreon] <Portal> received a falsy `target`',
64
+ '[Pyreon] <Portal> target must be a DOM node',
65
+ '[Pyreon] Invalid VNode type',
66
+ 'is a void element and cannot have children',
67
+ ],
68
+ },
69
+ {
70
+ file: 'props.ts',
71
+ devWarningStrings: [
72
+ '[Pyreon] Event handler',
73
+ '[Pyreon] Blocked unsafe URL',
74
+ ],
75
+ },
76
+ {
77
+ file: 'transition.ts',
78
+ devWarningStrings: [
79
+ '[Pyreon] Transition child is a component',
80
+ ],
81
+ },
82
+ ]
83
+
84
+ async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
85
+ const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
86
+ 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.
90
+ await build({
91
+ mode: dev ? 'development' : 'production',
92
+ logLevel: 'error',
93
+ configFile: false,
94
+ 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
+ define: {
98
+ 'import.meta.env.DEV': JSON.stringify(dev),
99
+ 'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
100
+ },
101
+ build: {
102
+ // PINNED minifier: 'esbuild' is what Pyreon's reference consumers
103
+ // (Zero, the example apps) effectively use. If a future Vite
104
+ // version flips the default to oxc-minify or terser, behavior
105
+ // could differ silently — pinning keeps this test honest.
106
+ minify: dev ? false : 'esbuild',
107
+ target: 'esnext',
108
+ write: true,
109
+ outDir,
110
+ emptyOutDir: true,
111
+ lib: {
112
+ entry,
113
+ formats: ['es'],
114
+ fileName: 'out',
115
+ },
116
+ // Bundle everything — we want the tested file's strings visible
117
+ // in the output, not aliased to an external import.
118
+ rollupOptions: {
119
+ external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
120
+ },
121
+ },
122
+ })
123
+ const outPath = path.join(outDir, 'out.js')
124
+ const fs = await import('node:fs')
125
+ return fs.readFileSync(outPath, 'utf8')
126
+ } finally {
127
+ rmSync(outDir, { recursive: true, force: true })
128
+ }
129
+ }
130
+
131
+ describe('runtime-dom dev-warning gate (Vite production bundle)', () => {
132
+ for (const { file, devWarningStrings } of FILES_UNDER_TEST) {
133
+ it(`${file} → dev warnings eliminated in Vite production bundle`, async () => {
134
+ const code = await bundleWithVite(path.join(SRC, file), false)
135
+
136
+ for (const warn of devWarningStrings) {
137
+ expect(code, `"${warn}" survived prod tree-shake`).not.toContain(warn)
138
+ }
139
+ expect(code.length).toBeGreaterThan(0)
140
+ }, 5000)
141
+
142
+ it(`${file} → dev warnings PRESERVED in Vite dev bundle (sanity)`, async () => {
143
+ // Gate for the eliminated-when-prod test: if the strings were
144
+ // deleted from source entirely, the previous test would pass
145
+ // trivially. Bundling in dev mode should keep them.
146
+ if (devWarningStrings.length === 0) return
147
+
148
+ const code = await bundleWithVite(path.join(SRC, file), true)
149
+
150
+ for (const warn of devWarningStrings) {
151
+ expect(code, `"${warn}" missing from dev bundle (did source change?)`).toContain(warn)
152
+ }
153
+ }, 5000)
154
+ }
155
+ })
156
+
157
+ // ─── Non-Vite consumer runtime correctness ─────────────────────────────────
158
+ //
159
+ // What the CLAUDE.md doc claims for non-Vite consumers (webpack,
160
+ // bunchee, raw esbuild bundles): the dev-warning STRINGS may stay in
161
+ // the bundle as data, but the warnings themselves don't fire because
162
+ // the `import.meta.env?.DEV === true` gate evaluates to `false` when
163
+ // `import.meta.env.DEV` is undefined at runtime.
164
+ //
165
+ // This block bundles `nodes.ts` with raw esbuild (no `define` for
166
+ // import.meta.env, simulating a less-aware bundler), then asserts:
167
+ //
168
+ // 1. The dev-warning strings DO survive (proving we picked a real
169
+ // bundle to test, not Vite-equivalent behavior).
170
+ // 2. The strings are still gated — they appear next to a check
171
+ // involving `import.meta.env` rather than being unconditional.
172
+ //
173
+ // (2) is what makes the runtime claim true: at runtime `import.meta.env`
174
+ // is `undefined` in non-Vite-aware environments, so `?.DEV` returns
175
+ // `undefined`, `=== true` returns `false`, and the warn never fires.
176
+ // If a future refactor unconditionally calls console.warn (no gate),
177
+ // this assertion catches that the runtime contract regressed.
178
+
179
+ describe('non-Vite consumer runtime correctness', () => {
180
+ it('raw esbuild bundle: warning strings remain in bundle (proves we test the non-Vite path)', async () => {
181
+ const { build } = await import('esbuild')
182
+ const result = await build({
183
+ entryPoints: [path.join(SRC, 'nodes.ts')],
184
+ bundle: true,
185
+ write: false,
186
+ minify: true,
187
+ format: 'esm',
188
+ platform: 'browser',
189
+ external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
190
+ // Intentionally no `define` — simulates a non-Vite-aware bundler.
191
+ })
192
+ const code = result.outputFiles[0]?.text ?? ''
193
+ expect(code).toContain('Duplicate key')
194
+ }, 5000)
195
+
196
+ it('raw esbuild bundle: dev gate evaluates to false at runtime when import.meta.env is undefined', async () => {
197
+ // The real claim is RUNTIME — even when warning strings are in the
198
+ // bundle, the gate stops `console.warn` from firing. This test
199
+ // EXECUTES the bundled module with `import.meta.env` undefined
200
+ // (the non-Vite case) and verifies `console.warn` is never called.
201
+ //
202
+ // Bundle a synthetic harness that exposes the gated callsite as a
203
+ // standalone exported function, replacing the cross-package
204
+ // imports so we don't need a full Pyreon runtime to execute. The
205
+ // harness mirrors the EXACT gate pattern used in nodes.ts.
206
+ const { build } = await import('esbuild')
207
+ const harness = `
208
+ // Same module-scope const pattern used in real Pyreon source.
209
+ // @ts-ignore — \`import.meta.env\` is provided by Vite at build time
210
+ const __DEV__ = import.meta.env?.DEV === true
211
+ export function maybeWarn(seen: Set<string>, key: string): void {
212
+ // Mirrors nodes.ts: a chained \`__DEV__ && cond && warn\` form
213
+ // (Pattern B from the C-2 probe).
214
+ if (seen.has(key)) {
215
+ if (__DEV__) {
216
+ console.warn(\`[Pyreon] Duplicate key "\${String(key)}" in <For> list.\`)
217
+ }
218
+ }
219
+ seen.add(key)
220
+ }
221
+ `
222
+ const result = await build({
223
+ stdin: { contents: harness, loader: 'ts', resolveDir: SRC },
224
+ bundle: true,
225
+ write: false,
226
+ minify: true,
227
+ format: 'esm',
228
+ platform: 'browser',
229
+ // No `define` — same as a non-Vite consumer.
230
+ })
231
+ const code = result.outputFiles[0]?.text ?? ''
232
+
233
+ // The string MUST be in the bundle (proves this is the non-Vite path).
234
+ expect(code).toContain('Duplicate key')
235
+
236
+ // Now actually execute the bundled module with `import.meta.env`
237
+ // resembling the non-Vite environment (undefined). Use a data:
238
+ // import to load the bundled ESM module. Bun supports this.
239
+ const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
240
+ const mod = (await import(/* @vite-ignore */ dataUrl)) as {
241
+ maybeWarn: (s: Set<string>, k: string) => void
242
+ }
243
+
244
+ // Spy on console.warn — the real runtime check.
245
+ const calls: unknown[][] = []
246
+ const original = console.warn
247
+ console.warn = (...args: unknown[]) => {
248
+ calls.push(args)
249
+ }
250
+ try {
251
+ const seen = new Set<string>()
252
+ mod.maybeWarn(seen, 'foo')
253
+ mod.maybeWarn(seen, 'foo') // second call → seen.has('foo') is true → would warn if gate broken
254
+ } finally {
255
+ console.warn = original
256
+ }
257
+
258
+ // The runtime contract: warning string is in the bundle (data),
259
+ // but the gate stops it from firing.
260
+ expect(calls).toEqual([])
261
+ }, 5000)
262
+ })
@@ -219,21 +219,22 @@ describe('mount — refs', () => {
219
219
  expect(refEl).toBeInstanceOf(HTMLDivElement)
220
220
  })
221
221
 
222
- test('callback ref element is not nulled on unmount', () => {
222
+ test('callback ref is invoked with null on unmount', () => {
223
223
  const el = container()
224
224
  let refEl: Element | null = null
225
225
  const unmount = mount(
226
226
  h('div', {
227
- ref: (e: Element) => {
227
+ ref: (e: Element | null) => {
228
228
  refEl = e
229
229
  },
230
230
  }),
231
231
  el,
232
232
  )
233
- expect(refEl).not.toBeNull()
234
- unmount()
235
- // Callback refs don't get called with null on cleanup
236
233
  expect(refEl).toBeInstanceOf(HTMLDivElement)
234
+ unmount()
235
+ // Fixed: callback refs are now called with null on cleanup
236
+ // to match React/Solid/Vue behavior and the RefCallback<T> type.
237
+ expect(refEl).toBeNull()
237
238
  })
238
239
 
239
240
  test('ref is not emitted as an HTML attribute', () => {
@@ -3176,6 +3177,95 @@ describe('TransitionGroup — cleanup', () => {
3176
3177
  })
3177
3178
  })
3178
3179
 
3180
+ // ─── TransitionGroup — leak regression tests ─────────────────────────────────
3181
+ // Regression for the two fixes:
3182
+ // 1. No safety timeout on applyLeave meant an item whose transition never
3183
+ // fired stayed in the `entries` Map forever (`entries.delete(key)` was
3184
+ // gated on the `done` callback firing).
3185
+ // 2. Unmount during in-flight transition left the 5s safety timer running,
3186
+ // firing `onAfterEnter` / `onAfterLeave` on detached elements.
3187
+
3188
+ describe('TransitionGroup — leak regressions', () => {
3189
+ beforeEach(() => {
3190
+ vi.useFakeTimers()
3191
+ })
3192
+ afterEach(() => {
3193
+ vi.useRealTimers()
3194
+ })
3195
+
3196
+ test('onAfterLeave fires via 5s safety timeout when transitionend never fires', async () => {
3197
+ const el = container()
3198
+ const items = signal([{ id: 1 }, { id: 2 }])
3199
+ const onAfterLeave = vi.fn()
3200
+ mount(
3201
+ h(TransitionGroup, {
3202
+ tag: 'div',
3203
+ name: 'fade',
3204
+ items,
3205
+ keyFn: (item: { id: number }) => item.id,
3206
+ render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3207
+ onAfterLeave,
3208
+ }),
3209
+ el,
3210
+ )
3211
+ await vi.advanceTimersByTimeAsync(20)
3212
+ items.set([{ id: 1 }])
3213
+ await vi.advanceTimersByTimeAsync(20)
3214
+ // transitionend never fires — before the fix this would leak forever.
3215
+ expect(onAfterLeave).not.toHaveBeenCalled()
3216
+ await vi.advanceTimersByTimeAsync(5100)
3217
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
3218
+ })
3219
+
3220
+ test('onAfterEnter does NOT fire after container unmount during in-flight enter', async () => {
3221
+ const el = container()
3222
+ const items = signal<{ id: number }[]>([])
3223
+ const onAfterEnter = vi.fn()
3224
+ const dispose = mount(
3225
+ h(TransitionGroup, {
3226
+ tag: 'div',
3227
+ name: 'fade',
3228
+ items,
3229
+ keyFn: (item: { id: number }) => item.id,
3230
+ render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3231
+ onAfterEnter,
3232
+ }),
3233
+ el,
3234
+ )
3235
+ await vi.advanceTimersByTimeAsync(20)
3236
+ items.set([{ id: 1 }])
3237
+ await vi.advanceTimersByTimeAsync(20)
3238
+ // Mid-transition — unmount. The 5s safety timer must NOT fire the
3239
+ // callback on a detached element.
3240
+ dispose()
3241
+ await vi.advanceTimersByTimeAsync(6000)
3242
+ expect(onAfterEnter).not.toHaveBeenCalled()
3243
+ })
3244
+
3245
+ test('onAfterLeave does NOT fire after container unmount during in-flight leave', async () => {
3246
+ const el = container()
3247
+ const items = signal([{ id: 1 }])
3248
+ const onAfterLeave = vi.fn()
3249
+ const dispose = mount(
3250
+ h(TransitionGroup, {
3251
+ tag: 'div',
3252
+ name: 'fade',
3253
+ items,
3254
+ keyFn: (item: { id: number }) => item.id,
3255
+ render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3256
+ onAfterLeave,
3257
+ }),
3258
+ el,
3259
+ )
3260
+ await vi.advanceTimersByTimeAsync(20)
3261
+ items.set([])
3262
+ await vi.advanceTimersByTimeAsync(20)
3263
+ dispose()
3264
+ await vi.advanceTimersByTimeAsync(6000)
3265
+ expect(onAfterLeave).not.toHaveBeenCalled()
3266
+ })
3267
+ })
3268
+
3179
3269
  // ─── Error paths (no ErrorBoundary) ──────────────────────────────────────────
3180
3270
 
3181
3271
  describe('mount — error paths', () => {
@@ -269,6 +269,123 @@ describe('applyProp — innerHTML', () => {
269
269
  expect(warnSpy).not.toHaveBeenCalled()
270
270
  warnSpy.mockRestore()
271
271
  })
272
+
273
+ test('reactive innerHTML accessor — function value is called, not stringified', async () => {
274
+ // Regression: the JSX compiler emits `innerHTML={getIcon(props.x ? "a" : "b")}`
275
+ // as a `() => …` accessor. Without function-value handling here, the
276
+ // closure was set as literal text — `() => getIcon(...)` rendered
277
+ // verbatim instead of the SVG.
278
+ const { signal } = await import('@pyreon/reactivity')
279
+ const el = document.createElement('div')
280
+ const which = signal<'a' | 'b'>('a')
281
+ const cleanup = applyProp(el, 'innerHTML', () => `<span data-x="${which()}">x</span>`)
282
+ expect(el.querySelector('[data-x="a"]')).not.toBeNull()
283
+ expect(el.innerHTML).not.toContain('=>')
284
+ which.set('b')
285
+ expect(el.querySelector('[data-x="b"]')).not.toBeNull()
286
+ cleanup?.()
287
+ })
288
+
289
+ test('reactive dangerouslySetInnerHTML accessor — function value is called, not stringified', async () => {
290
+ const { signal } = await import('@pyreon/reactivity')
291
+ const el = document.createElement('div')
292
+ const html = signal('<em>one</em>')
293
+ const cleanup = applyProp(el, 'dangerouslySetInnerHTML', () => ({ __html: html() }))
294
+ expect(el.innerHTML).toBe('<em>one</em>')
295
+ html.set('<em>two</em>')
296
+ expect(el.innerHTML).toBe('<em>two</em>')
297
+ cleanup?.()
298
+ })
299
+
300
+ test('dev warning fires if a function reaches applyStaticProp directly (defensive guard)', () => {
301
+ // applyStaticProp is internal — reachable only if a future special-case
302
+ // branch in applyProp bypasses the reactive-wrap dance. The dev guard
303
+ // catches that regression at first render.
304
+ const el = document.createElement('div')
305
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
306
+ // Indirect: trigger by routing a function through `applyProp` for a
307
+ // key that DOESN'T have a special case — exercises the reactive path,
308
+ // which calls the accessor + passes the result. The accessor itself
309
+ // returning a function would surface the warning.
310
+ applyProp(el, 'innerHTML', () => () => '<em>nested</em>')
311
+ expect(warnSpy).toHaveBeenCalledWith(
312
+ expect.stringContaining('applyStaticProp received a function for "innerHTML"'),
313
+ )
314
+ warnSpy.mockRestore()
315
+ })
316
+ })
317
+
318
+ // Comprehensive sweep: every string-typed sink must handle reactive
319
+ // (function) values. The original bug was specific to innerHTML, but the
320
+ // structural fix should cover ALL sinks the same way. These tests assert
321
+ // that.
322
+ describe('applyProp — reactive function values across all sink kinds', () => {
323
+ test('reactive href accessor on <a>', async () => {
324
+ const { signal } = await import('@pyreon/reactivity')
325
+ const el = document.createElement('a')
326
+ const path = signal('/one')
327
+ const cleanup = applyProp(el, 'href', () => path())
328
+ expect(el.getAttribute('href')).toBe('/one')
329
+ path.set('/two')
330
+ expect(el.getAttribute('href')).toBe('/two')
331
+ cleanup?.()
332
+ })
333
+
334
+ test('reactive src accessor on <img>', async () => {
335
+ const { signal } = await import('@pyreon/reactivity')
336
+ const el = document.createElement('img')
337
+ const url = signal('/a.png')
338
+ const cleanup = applyProp(el, 'src', () => url())
339
+ // <img> exposes `src` as a normalized absolute URL — assert via getAttribute
340
+ expect(el.getAttribute('src')).toBe('/a.png')
341
+ url.set('/b.png')
342
+ expect(el.getAttribute('src')).toBe('/b.png')
343
+ cleanup?.()
344
+ })
345
+
346
+ test('reactive value accessor on <input>', async () => {
347
+ const { signal } = await import('@pyreon/reactivity')
348
+ const el = document.createElement('input')
349
+ const val = signal('alpha')
350
+ const cleanup = applyProp(el, 'value', () => val())
351
+ expect((el as HTMLInputElement).value).toBe('alpha')
352
+ val.set('beta')
353
+ expect((el as HTMLInputElement).value).toBe('beta')
354
+ cleanup?.()
355
+ })
356
+
357
+ test('reactive title accessor (data attribute pattern)', async () => {
358
+ const { signal } = await import('@pyreon/reactivity')
359
+ const el = document.createElement('div')
360
+ const tip = signal('hello')
361
+ const cleanup = applyProp(el, 'title', () => tip())
362
+ expect(el.getAttribute('title')).toBe('hello')
363
+ tip.set('world')
364
+ expect(el.getAttribute('title')).toBe('world')
365
+ cleanup?.()
366
+ })
367
+
368
+ test('reactive class accessor (string form)', async () => {
369
+ const { signal } = await import('@pyreon/reactivity')
370
+ const el = document.createElement('div')
371
+ const cls = signal('one')
372
+ const cleanup = applyProp(el, 'class', () => cls())
373
+ expect(el.className).toBe('one')
374
+ cls.set('two')
375
+ expect(el.className).toBe('two')
376
+ cleanup?.()
377
+ })
378
+
379
+ test('reactive style accessor (object form)', async () => {
380
+ const { signal } = await import('@pyreon/reactivity')
381
+ const el = document.createElement('div')
382
+ const color = signal('red')
383
+ const cleanup = applyProp(el, 'style', () => ({ color: color() }))
384
+ expect(el.style.color).toBe('red')
385
+ color.set('blue')
386
+ expect(el.style.color).toBe('blue')
387
+ cleanup?.()
388
+ })
272
389
  })
273
390
 
274
391
  // ─── applyProp — URL safety ──────────────────────────────────────────────────