@pyreon/runtime-dom 0.14.0 → 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.
@@ -7,12 +7,14 @@ import {
7
7
  For,
8
8
  Fragment,
9
9
  h,
10
+ lazy,
10
11
  Match,
11
12
  onMount,
12
13
  onUnmount,
13
14
  onUpdate,
14
15
  Portal,
15
16
  Show,
17
+ Suspense as _Suspense,
16
18
  Switch,
17
19
  } from '@pyreon/core'
18
20
  import { cell, signal } from '@pyreon/reactivity'
@@ -36,6 +38,7 @@ const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>
36
38
  const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
37
39
  const ErrorBoundary = _ErrorBoundary as unknown as ComponentFn<Record<string, unknown>>
38
40
  const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
41
+ const Suspense = _Suspense as unknown as ComponentFn<Record<string, unknown>>
39
42
 
40
43
  function container(): HTMLElement {
41
44
  const el = document.createElement('div')
@@ -773,6 +776,94 @@ describe('ErrorBoundary', () => {
773
776
  ;(el.querySelector('#fix') as HTMLButtonElement).click()
774
777
  expect(el.querySelector('#signal-ok')?.textContent).toBe('fixed')
775
778
  })
779
+
780
+ // ── lazy() + Suspense + ErrorBoundary integration ──
781
+ //
782
+ // The `lazy(loader)` wrapper throws synchronously when its loader's
783
+ // promise rejects (`error()` returns truthy → `throw err`).
784
+ //
785
+ // Pyreon components run ONCE — reactivity comes from reading signals
786
+ // inside reactive scopes. `lazy()`'s wrapper reads its `error` /
787
+ // `loaded` signals inline, so the surrounding context must be a
788
+ // reactive scope for signal changes to trigger re-render.
789
+ //
790
+ // `Suspense` wraps its children in `h(Fragment, null, () => ...)` —
791
+ // an explicit reactive accessor that calls `__loading()`. THAT
792
+ // accessor's reactive scope is what tracks lazy's signals: when the
793
+ // loader rejects, the accessor re-runs, the lazy child re-mounts,
794
+ // the wrapper throws, mountComponent catches, dispatches to the
795
+ // nearest `<ErrorBoundary>` on the boundary stack.
796
+ //
797
+ // Without Suspense, lazy()'s post-mount errors don't surface (no
798
+ // reactive scope to drive re-render). This is consistent with the
799
+ // framework's component-runs-once contract — but worth pinning
800
+ // down with explicit tests.
801
+
802
+ test('lazy() loader rejection surfaces to ErrorBoundary via Suspense', async () => {
803
+ const el = container()
804
+ const Comp = lazy<Record<string, never>>(() =>
805
+ Promise.reject(new Error('module load failed')),
806
+ )
807
+
808
+ mount(
809
+ h(ErrorBoundary, {
810
+ fallback: (err: unknown) =>
811
+ h('p', { id: 'lazy-fb' }, `Caught: ${(err as Error).message}`),
812
+ children: h(
813
+ Suspense,
814
+ { fallback: h('p', { id: 'spinner' }, 'loading...') },
815
+ h(Comp, {}),
816
+ ),
817
+ }),
818
+ el,
819
+ )
820
+
821
+ // Initial render: lazy is still loading → Suspense shows spinner,
822
+ // boundary fallback NOT triggered yet.
823
+ expect(el.querySelector('#spinner')).not.toBeNull()
824
+ expect(el.querySelector('#lazy-fb')).toBeNull()
825
+
826
+ // Wait for promise rejection to flush.
827
+ await new Promise((r) => setTimeout(r, 0))
828
+ // Reactive flush.
829
+ await Promise.resolve()
830
+
831
+ // After load fails: Suspense's reactive accessor re-runs → child
832
+ // wrapper throws → caught by mountComponent → dispatched to
833
+ // ErrorBoundary → fallback rendered.
834
+ expect(el.querySelector('#lazy-fb')?.textContent).toContain('module load failed')
835
+ expect(el.querySelector('#spinner')).toBeNull()
836
+ })
837
+
838
+ test('lazy() resolves successfully renders content without firing fallback', async () => {
839
+ const el = container()
840
+ const Inner: ComponentFn<Record<string, never>> = () =>
841
+ h('p', { id: 'loaded' }, 'content')
842
+ const Comp = lazy<Record<string, never>>(() => Promise.resolve({ default: Inner }))
843
+
844
+ let fallbackInvocations = 0
845
+ mount(
846
+ h(ErrorBoundary, {
847
+ fallback: () => {
848
+ fallbackInvocations++
849
+ return h('p', { id: 'should-not-appear' }, 'error')
850
+ },
851
+ children: h(
852
+ Suspense,
853
+ { fallback: h('p', { id: 'spinner' }, 'loading...') },
854
+ h(Comp, {}),
855
+ ),
856
+ }),
857
+ el,
858
+ )
859
+
860
+ await new Promise((r) => setTimeout(r, 0))
861
+ await Promise.resolve()
862
+
863
+ expect(el.querySelector('#loaded')?.textContent).toBe('content')
864
+ expect(el.querySelector('#should-not-appear')).toBeNull()
865
+ expect(fallbackInvocations).toBe(0)
866
+ })
776
867
  })
777
868
 
778
869
  // ─── Transition component ─────────────────────────────────────────────────────
@@ -0,0 +1,19 @@
1
+ import { isNativeCompat } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { KeepAlive } from '../keep-alive'
4
+ import { Transition } from '../transition'
5
+ import { TransitionGroup } from '../transition-group'
6
+
7
+ // Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
8
+ // `nativeCompat(...)` from any of these files fails the corresponding test.
9
+ describe('native-compat markers — @pyreon/runtime-dom', () => {
10
+ it('Transition is marked native', () => {
11
+ expect(isNativeCompat(Transition)).toBe(true)
12
+ })
13
+ it('TransitionGroup is marked native', () => {
14
+ expect(isNativeCompat(TransitionGroup)).toBe(true)
15
+ })
16
+ it('KeepAlive is marked native', () => {
17
+ expect(isNativeCompat(KeepAlive)).toBe(true)
18
+ })
19
+ })
@@ -149,6 +149,57 @@ describe('runtime-dom in real browser', () => {
149
149
  unmount()
150
150
  })
151
151
 
152
+ it('delegated event handler sees `currentTarget` as the bound element, not the listener root', async () => {
153
+ // Regression for a real Pyreon framework bug found via PR #329's form
154
+ // section. Pyreon's TargetedEvent<E> type promises `currentTarget` is
155
+ // the per-element type (e.g. HTMLInputElement), but native event
156
+ // delegation leaves `currentTarget` as the container (where the
157
+ // listener is registered). User code that writes
158
+ // `(ev.currentTarget as HTMLInputElement).value` would silently read
159
+ // from a <div> and get undefined.
160
+ //
161
+ // The fix is in delegate.ts: per-handler Object.defineProperty
162
+ // override of currentTarget, matching React/Vue/Solid behavior.
163
+ const { container, unmount } = mountInBrowser(
164
+ h(
165
+ 'div',
166
+ { id: 'wrap' },
167
+ h('input', {
168
+ id: 'inp',
169
+ type: 'text',
170
+ 'data-marker': 'real-input',
171
+ onInput: (ev: Event) => {
172
+ const ct = ev.currentTarget as HTMLInputElement | null
173
+ // Capture observable signals so the test can assert on them
174
+ ;(globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag = ct?.tagName
175
+ ;(globalThis as { __test_ct_value?: string | undefined }).__test_ct_value =
176
+ ct?.value
177
+ ;(globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker =
178
+ ct?.getAttribute('data-marker') ?? null
179
+ },
180
+ }),
181
+ ),
182
+ )
183
+
184
+ const inp = container.querySelector<HTMLInputElement>('#inp')!
185
+ inp.value = 'hello'
186
+ inp.dispatchEvent(new Event('input', { bubbles: true }))
187
+ await flush()
188
+
189
+ // Without the fix: tagName would be 'DIV' (or whatever container is),
190
+ // value would be undefined, marker would be null.
191
+ expect((globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag).toBe('INPUT')
192
+ expect((globalThis as { __test_ct_value?: string | undefined }).__test_ct_value).toBe('hello')
193
+ expect((globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker).toBe(
194
+ 'real-input',
195
+ )
196
+
197
+ unmount()
198
+ delete (globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag
199
+ delete (globalThis as { __test_ct_value?: string | undefined }).__test_ct_value
200
+ delete (globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker
201
+ })
202
+
152
203
  it('dispatches a real PointerEvent and fires the onClick handler', async () => {
153
204
  const clicks = signal(0)
154
205
  const { container, unmount } = mountInBrowser(
@@ -177,12 +228,13 @@ describe('runtime-dom in real browser', () => {
177
228
  unmount()
178
229
  })
179
230
 
180
- it('emits the duplicate-key __DEV__ warning under Vite (DEV=true)', async () => {
181
- // import.meta.env.DEV is true in this dev-mode browser run, which is the
182
- // exact replacement Vite/Rolldown apply at build-time. The warning must
183
- // fire here. The companion `runtime-dom.prod-bundle.test.ts` Node test
184
- // proves the same code path is dead in a prod bundle (DEV=false).
185
- expect(import.meta.env.DEV).toBe(true)
231
+ it('emits the duplicate-key dev warning under non-production NODE_ENV', async () => {
232
+ // process.env.NODE_ENV !== 'production' in this dev browser run the
233
+ // bundler-agnostic gate that every modern bundler auto-replaces at
234
+ // consumer build time. The warning must fire here. The companion
235
+ // `runtime-dom.prod-bundle.test.ts` Node test proves the same code path
236
+ // is dead in a prod bundle (NODE_ENV='production').
237
+ expect(process.env.NODE_ENV).not.toBe('production')
186
238
 
187
239
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
188
240
  const dupes = signal([
@@ -174,4 +174,97 @@ describe('context inheritance through reactive boundaries', () => {
174
174
  expect(collected.length).toBe(3)
175
175
  expect(collected.every((v) => v === 'parent-provided')).toBe(true)
176
176
  })
177
+
178
+ // ── Lock-in for the context-truncation fix (PR #406) ──────────────────────
179
+ //
180
+ // Pre-fix `mountReactive`'s `restoreContextStack` did
181
+ // `stack.length = savedLength` in its finally block — which destroyed
182
+ // every provider frame that the synchronous mount had pushed via
183
+ // `provide()`. Signal-driven re-runs of `_bind` / `renderEffect` inside
184
+ // the mounted subtree later saw a half-empty stack and `useContext()`
185
+ // silently fell back to the default. The original symptom was
186
+ // `<PyreonUI mode={signal()}>` toggling not propagating to consumers
187
+ // — discovered while writing PR #406's regression e2e and traced back
188
+ // through the binding subscription chain to this stack truncation.
189
+ //
190
+ // The fix has two cooperating layers; each provides defense-in-depth
191
+ // for the other, so this assertion would still pass if you revert
192
+ // EITHER alone — but reverting BOTH layers together fails it. To
193
+ // bisect-verify cleanly, revert both:
194
+ // 1. `packages/core/core/src/context.ts:restoreContextStack` — change
195
+ // the finally block back to `stack.length = savedLength` (truncate
196
+ // everything fn() pushed).
197
+ // 2. `packages/core/reactivity/src/effect.ts:_bind` — remove the
198
+ // `_snapshotCapture` capture/restore wiring so re-runs call fn()
199
+ // against whatever the live stack happens to be at re-run time.
200
+ //
201
+ // With both reverted, this test fails with `seen[1] === 'default'`.
202
+ //
203
+ // What the test exercises: a `_bind` text binding inside a child mounted
204
+ // through a reactive accessor (which goes through `mountReactive`). The
205
+ // binding subscribes to a signal and reads `useContext(Ctx)`. After
206
+ // initial mount, the provider frame is at risk of being truncated by
207
+ // `mountReactive`'s cleanup — toggling the signal forces the binding to
208
+ // re-run, which re-reads context. If either fix is in place, the
209
+ // re-read finds the provider frame and returns the provided value.
210
+ it('binding re-runs preserve context lookup across mountReactive cleanup boundary (PR #406 splice + snapshot capture)', async () => {
211
+ const Ctx = createContext('default')
212
+ const trigger = signal(0)
213
+ let lastSeen: string | undefined
214
+ let runCount = 0
215
+
216
+ function Inner() {
217
+ // JSX text accessor compiles to a `_bind` / renderEffect text binding
218
+ // that subscribes to `trigger` (signal read inside the body) AND
219
+ // captures the external context snapshot at setup time.
220
+ return h('span', null, () => {
221
+ trigger()
222
+ const v = useContext(Ctx)
223
+ lastSeen = v
224
+ runCount++
225
+ return v
226
+ })
227
+ }
228
+
229
+ function Provider() {
230
+ // CRITICAL for exercising the bug: `provide()` runs INSIDE the
231
+ // reactive child fn (the accessor `() => h(Provider)` returned by
232
+ // App below). That puts Ctx on the stack DURING `mountReactive`'s
233
+ // restoreContextStack(snapshot, fn) execution — and pre-fix the
234
+ // truncating finally block (`stack.length = savedLength`) destroyed
235
+ // the frame the moment fn returned. If Outer pushed Ctx in its OWN
236
+ // body BEFORE returning the accessor, the frame would already be on
237
+ // the stack at snapshot-capture time and survive truncation
238
+ // unrelated — so the test wouldn't actually exercise the bug.
239
+ provide(Ctx, 'provider-value')
240
+ return h(Inner, null)
241
+ }
242
+
243
+ function App() {
244
+ // No provide() here — the provider frame must be pushed strictly
245
+ // inside the reactive accessor body (= inside `mountReactive`'s fn)
246
+ // so the truncation-vs-splice path is reached.
247
+ return () => h(Provider, null)
248
+ }
249
+
250
+ const container = document.createElement('div')
251
+ mount(h(App, null), container)
252
+ await new Promise((r) => setTimeout(r, 20))
253
+ expect(lastSeen).toBe('provider-value')
254
+ const initialRunCount = runCount
255
+
256
+ // Force the binding's effect to re-run AFTER the synchronous mount
257
+ // has fully unwound. With the broken pre-fix shape, mountReactive's
258
+ // `stack.length = savedLength` finally block has already destroyed
259
+ // the Ctx frame Provider pushed, so this re-run reads useContext
260
+ // against a stack that no longer contains the provider — and
261
+ // `lastSeen` becomes `'default'`.
262
+ trigger.set(1)
263
+ await new Promise((r) => setTimeout(r, 20))
264
+ expect(lastSeen).toBe('provider-value')
265
+ // Sanity: the re-run actually happened. Otherwise the test could
266
+ // pass for the wrong reason (e.g. if the trigger subscription wasn't
267
+ // established because the accessor didn't read `trigger()` reactively).
268
+ expect(runCount).toBeGreaterThan(initialRunCount)
269
+ })
177
270
  })
@@ -1,5 +1,5 @@
1
1
  import { computed, signal } from '@pyreon/reactivity'
2
- import { _bindDirect, _bindText } from '../template'
2
+ import { _bindDirect, _bindText, _clearTplCache, _tpl, _tplCacheSize } from '../template'
3
3
 
4
4
  // ─── _bindText ──────────────────────────────────────────────────────────────
5
5
 
@@ -311,3 +311,73 @@ describe('_mountSlot', async () => {
311
311
  expect(parent.childNodes.length).toBe(0)
312
312
  })
313
313
  })
314
+
315
+ // ─── Audit bug #5: _tplCache LRU eviction ───────────────────────────────────
316
+ //
317
+ // The cache is an LRU-bounded Map keyed on the HTML string. Typed JSX
318
+ // produces a small bounded set of unique HTML strings — most apps stay in
319
+ // the dozens-to-hundreds. But an app that constructs JSX from user input
320
+ // or compiles many large dynamic templates could grow this unbounded
321
+ // pre-fix. The cap at 1024 entries keeps memory predictable while being
322
+ // generous enough that no realistic codebase hits it.
323
+
324
+ describe('_tpl cache — LRU eviction (audit bug #5)', () => {
325
+ const TPL_CACHE_MAX = 1024
326
+
327
+ test('cache stays bounded when more than MAX unique templates are emitted', () => {
328
+ _clearTplCache()
329
+ const noBind = (): null => null
330
+
331
+ // Emit 1.5x the cap of unique templates — without LRU bound, cache
332
+ // would grow to 1536 entries.
333
+ const overshoot = Math.floor(TPL_CACHE_MAX * 1.5)
334
+ for (let i = 0; i < overshoot; i++) {
335
+ _tpl(`<div data-i="${i}">${i}</div>`, noBind)
336
+ }
337
+
338
+ expect(_tplCacheSize()).toBeLessThanOrEqual(TPL_CACHE_MAX)
339
+ expect(_tplCacheSize()).toBeGreaterThan(0)
340
+ })
341
+
342
+ test('eviction is oldest-first; recently-touched entries survive', () => {
343
+ _clearTplCache()
344
+ const noBind = (): null => null
345
+
346
+ // Fill the cache to the cap.
347
+ const baseHtml = (i: number): string => `<span data-i="${i}">${i}</span>`
348
+ for (let i = 0; i < TPL_CACHE_MAX; i++) _tpl(baseHtml(i), noBind)
349
+ expect(_tplCacheSize()).toBe(TPL_CACHE_MAX)
350
+
351
+ // Touch entry 0 (the oldest). Map insertion-order semantics mean a
352
+ // re-insert after delete moves it to the most-recent position.
353
+ _tpl(baseHtml(0), noBind)
354
+
355
+ // Add ONE new entry — the OLDEST untouched entry should evict, NOT entry 0.
356
+ _tpl('<p>brand-new</p>', noBind)
357
+
358
+ expect(_tplCacheSize()).toBe(TPL_CACHE_MAX)
359
+
360
+ // Touch entry 0 again — if eviction policy were broken and entry 0
361
+ // had been evicted, this re-creates it. We need a way to assert it
362
+ // was retained. Approach: count cache misses by checking size delta.
363
+ // Adding the brand-new entry above evicted ONE; the cache stayed at
364
+ // cap. If we now add 1 more brand-new entry without re-using existing
365
+ // keys, size stays at cap. If we re-touch entry 0, size also stays at
366
+ // cap (already cached). The assertion: re-emitting entry 0 must NOT
367
+ // grow the cache (cache hit, not miss).
368
+ const sizeBeforeReHit = _tplCacheSize()
369
+ _tpl(baseHtml(0), noBind)
370
+ expect(_tplCacheSize()).toBe(sizeBeforeReHit) // re-emit was a hit
371
+ })
372
+
373
+ test('repeated emit of same template produces ONE cached entry', () => {
374
+ _clearTplCache()
375
+ const noBind = (): null => null
376
+
377
+ for (let i = 0; i < 100; i++) {
378
+ _tpl('<div class="static"></div>', noBind)
379
+ }
380
+
381
+ expect(_tplCacheSize()).toBe(1)
382
+ })
383
+ })
@@ -1,5 +1,5 @@
1
1
  import type { Props, VNode, VNodeChild } from '@pyreon/core'
2
- import { createRef, h, onMount, onUnmount } from '@pyreon/core'
2
+ import { createRef, h, nativeCompat, onMount, onUnmount } from '@pyreon/core'
3
3
  import { effect, runUntracked, signal } from '@pyreon/reactivity'
4
4
  import { mountChild } from './mount'
5
5
 
@@ -333,3 +333,8 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
333
333
 
334
334
  return h(tag, { ref: containerRef })
335
335
  }
336
+
337
+ // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
338
+ // TransitionGroup uses signal/effect/onMount/onUnmount + mountChild that
339
+ // need Pyreon's setup frame.
340
+ nativeCompat(TransitionGroup)
package/src/transition.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Props, VNode, VNodeChild } from '@pyreon/core'
2
- import { createRef, Fragment, h, onUnmount } from '@pyreon/core'
2
+ import { createRef, Fragment, h, nativeCompat, onUnmount } from '@pyreon/core'
3
3
  import { effect, runUntracked, signal } from '@pyreon/reactivity'
4
4
 
5
5
  // Dev-mode gate: `import.meta.env.DEV` is the Vite/Rolldown standard,
@@ -7,8 +7,7 @@ import { effect, runUntracked, signal } from '@pyreon/reactivity'
7
7
  // pattern was dead code in real Vite browser bundles because Vite does not
8
8
  // polyfill `process` for the client — every wrapped warning silently never
9
9
  // fired in dev. Enforced by the `pyreon/no-process-dev-gate` lint rule.
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
  export interface TransitionProps {
14
13
  /**
@@ -189,6 +188,11 @@ export function Transition(props: TransitionProps): VNodeChild {
189
188
  applyLeave(el)
190
189
  }
191
190
 
191
+ // queueMicrotask defers the appear-animation to after the DOM has
192
+ // committed the initial mount — that scheduling IS the reactive
193
+ // subscription's job here (it tracks `props.show()` and `props.appear`
194
+ // and schedules the visual transition). Not setup work.
195
+ // pyreon-lint-disable-next-line pyreon/no-imperative-effect-on-create
192
196
  effect(() => {
193
197
  const visible = props.show()
194
198
  if (!initialized) {
@@ -235,3 +239,7 @@ export function Transition(props: TransitionProps): VNodeChild {
235
239
  return { ...vnode, props: { ...vnode.props, ref } as Props }
236
240
  }) as unknown as VNode
237
241
  }
242
+
243
+ // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
244
+ // Transition uses signal/effect/onUnmount that need Pyreon's setup frame.
245
+ nativeCompat(Transition)