@pyreon/runtime-dom 0.12.12 → 0.12.14

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,93 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // End-to-end XSS round-trip for the For-key-marker fix (PR #235).
4
+ // The happy-dom/Node tests assert the encoder produces safe-looking
5
+ // output; this one asserts a real Chromium browser parses the encoded
6
+ // SSR output without executing the injected script.
7
+ //
8
+ // runtime-server can't be imported in the browser (Node async_hooks),
9
+ // so we reconstruct SSR-shaped output directly: a pre-fix (vulnerable)
10
+ // string to prove the attack model, and a post-fix (encoded) string
11
+ // to prove the fix neutralizes it.
12
+
13
+ describe('SSR → real-browser round-trip — For-key marker XSS', () => {
14
+ afterEach(() => {
15
+ vi.restoreAllMocks()
16
+ })
17
+
18
+ it('unencoded attacker key in marker would execute script in Chromium (attack model)', async () => {
19
+ const win = window as Window & { __preFixFired?: boolean }
20
+ win.__preFixFired = false
21
+
22
+ // Build what PRE-fix SSR would have emitted: the raw attacker key
23
+ // interpolated directly into the comment.
24
+ const attackKey = `--><script>window.__preFixFired = true</script><!--`
25
+ const preFixHtml =
26
+ `<!--pyreon-for--><!--k:${attackKey}--><li>item</li><!--/pyreon-for-->`
27
+
28
+ const container = document.createElement('div')
29
+ // innerHTML does not execute <script> per HTML5 spec — so use
30
+ // document.write-free manual parsing via DOMParser, then adopt.
31
+ // This mirrors what a streaming renderer would produce.
32
+ const parsed = new DOMParser().parseFromString(
33
+ `<body>${preFixHtml}</body>`,
34
+ 'text/html',
35
+ )
36
+ // Move the parsed body's children into our container; re-insert
37
+ // script tags via createElement so they execute in the real page.
38
+ for (const node of Array.from(parsed.body.childNodes)) {
39
+ if (node.nodeType === 1 && (node as Element).tagName === 'SCRIPT') {
40
+ const s = document.createElement('script')
41
+ s.textContent = (node as HTMLScriptElement).textContent ?? ''
42
+ container.appendChild(s)
43
+ } else {
44
+ container.appendChild(node)
45
+ }
46
+ }
47
+ document.body.appendChild(container)
48
+ await new Promise((r) => setTimeout(r, 0))
49
+
50
+ // Attack model proof: the raw form DOES execute when re-inserted.
51
+ expect(win.__preFixFired).toBe(true)
52
+
53
+ container.remove()
54
+ delete win.__preFixFired
55
+ })
56
+
57
+ it('encoded marker (post-fix) does NOT execute script in Chromium', async () => {
58
+ const win = window as Window & { __postFixFired?: boolean }
59
+ win.__postFixFired = false
60
+
61
+ // Build what POST-fix SSR emits after safeKeyForMarker:
62
+ // encodeURIComponent then all `-` → `%2D`.
63
+ const attackKey = `--><script>window.__postFixFired = true</script><!--`
64
+ const encoded = encodeURIComponent(attackKey).replace(/-/g, '%2D')
65
+ const postFixHtml =
66
+ `<!--pyreon-for--><!--k:${encoded}--><li>item</li><!--/pyreon-for-->`
67
+
68
+ const container = document.createElement('div')
69
+ const parsed = new DOMParser().parseFromString(
70
+ `<body>${postFixHtml}</body>`,
71
+ 'text/html',
72
+ )
73
+ for (const node of Array.from(parsed.body.childNodes)) {
74
+ if (node.nodeType === 1 && (node as Element).tagName === 'SCRIPT') {
75
+ const s = document.createElement('script')
76
+ s.textContent = (node as HTMLScriptElement).textContent ?? ''
77
+ container.appendChild(s)
78
+ } else {
79
+ container.appendChild(node)
80
+ }
81
+ }
82
+ document.body.appendChild(container)
83
+ await new Promise((r) => setTimeout(r, 0))
84
+
85
+ // Post-fix: no script was parsed out of the marker. Encoded key
86
+ // stays inside the comment; nothing to execute.
87
+ expect(win.__postFixFired).toBe(false)
88
+ expect(container.querySelector('script')).toBeNull()
89
+
90
+ container.remove()
91
+ delete win.__postFixFired
92
+ })
93
+ })
@@ -0,0 +1,54 @@
1
+ import { h } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ // Real-Chromium smoke for the #233 style-key-removal fix. happy-dom can
7
+ // pass because its CSSStyleDeclaration stub is forgiving — this suite
8
+ // asserts the fix holds up against a real engine's styles bag.
9
+
10
+ describe('reactive style — stale keys removed (real browser)', () => {
11
+ afterEach(() => {
12
+ vi.restoreAllMocks()
13
+ })
14
+
15
+ it('drops a key that disappears from a reactive style object', async () => {
16
+ const style = signal<Record<string, string>>({ color: 'rgb(255, 0, 0)', fontSize: '14px' })
17
+ const { container, unmount } = mountInBrowser(
18
+ h('div', { id: 's1', style: () => style() }, 'x'),
19
+ )
20
+
21
+ const el = container.querySelector<HTMLDivElement>('#s1')!
22
+ expect(el.style.color).toBe('rgb(255, 0, 0)')
23
+ expect(el.style.fontSize).toBe('14px')
24
+
25
+ style.set({ color: 'rgb(255, 0, 0)' })
26
+ await flush()
27
+
28
+ expect(el.style.color).toBe('rgb(255, 0, 0)')
29
+ // Chromium reports the removed longhand as an empty string on the
30
+ // inline style. This is exactly what #233 intended and what happy-dom
31
+ // only started reporting after the fix landed.
32
+ expect(el.style.fontSize).toBe('')
33
+ unmount()
34
+ })
35
+
36
+ it('clears every tracked key when style becomes null', async () => {
37
+ const style = signal<Record<string, string> | null>({
38
+ color: 'rgb(0, 0, 255)',
39
+ padding: '10px',
40
+ })
41
+ const { container, unmount } = mountInBrowser(
42
+ h('div', { id: 's2', style: () => style() }, 'x'),
43
+ )
44
+ const el = container.querySelector<HTMLDivElement>('#s2')!
45
+ expect(el.style.color).toBe('rgb(0, 0, 255)')
46
+
47
+ style.set(null)
48
+ await flush()
49
+
50
+ expect(el.style.color).toBe('')
51
+ expect(el.style.padding).toBe('')
52
+ unmount()
53
+ })
54
+ })
@@ -0,0 +1,88 @@
1
+ import { h } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import { mount } from '../index'
4
+
5
+ describe('reactive style object — stale keys are removed', () => {
6
+ let container: HTMLDivElement
7
+
8
+ beforeEach(() => {
9
+ container = document.createElement('div')
10
+ document.body.appendChild(container)
11
+ })
12
+
13
+ afterEach(() => {
14
+ container.remove()
15
+ })
16
+
17
+ it('removes a property that disappears from a reactive style object', () => {
18
+ const style = signal<Record<string, string>>({ color: 'red', fontSize: '14px' })
19
+
20
+ mount(h('div', { style: () => style() }), container)
21
+
22
+ const el = container.querySelector('div') as HTMLDivElement
23
+ expect(el.style.color).toBe('red')
24
+ expect(el.style.fontSize).toBe('14px')
25
+
26
+ // Drop fontSize — previous behavior left it on the element.
27
+ style.set({ color: 'red' })
28
+
29
+ expect(el.style.color).toBe('red')
30
+ expect(el.style.fontSize).toBe('')
31
+ })
32
+
33
+ it('clears all object-mode keys when reactive style becomes null', () => {
34
+ const style = signal<Record<string, string> | null>({ color: 'blue', padding: '10px' })
35
+
36
+ mount(h('div', { style: () => style() }), container)
37
+
38
+ const el = container.querySelector('div') as HTMLDivElement
39
+ expect(el.style.color).toBe('blue')
40
+ expect(el.style.padding).toBe('10px')
41
+
42
+ style.set(null)
43
+
44
+ expect(el.style.color).toBe('')
45
+ expect(el.style.padding).toBe('')
46
+ })
47
+
48
+ it('clears every tracked key when reactive style shrinks to an empty object', () => {
49
+ // Empty-object edge flagged on PR #233: if the new value is `{}`, the
50
+ // `value == null` branch in applyStyleProp is skipped, but the key-diff
51
+ // loop still iterates the prior tracked set and removes each one.
52
+ const style = signal<Record<string, string>>({ color: 'green', margin: '2px' })
53
+
54
+ mount(h('div', { style: () => style() }), container)
55
+
56
+ const el = container.querySelector('div') as HTMLDivElement
57
+ expect(el.style.color).toBe('green')
58
+ expect(el.style.margin).toBe('2px')
59
+
60
+ style.set({})
61
+
62
+ expect(el.style.color).toBe('')
63
+ expect(el.style.margin).toBe('')
64
+ })
65
+
66
+ it('handles object → string → object transitions without leaking keys', () => {
67
+ const style = signal<string | Record<string, string>>({ color: 'red' })
68
+
69
+ mount(h('div', { style: () => style() }), container)
70
+
71
+ const el = container.querySelector('div') as HTMLDivElement
72
+ expect(el.style.color).toBe('red')
73
+
74
+ // Swap to string form — cssText replaces everything.
75
+ style.set('background: yellow;')
76
+ expect(el.style.color).toBe('')
77
+ expect(el.style.background).toContain('yellow')
78
+
79
+ // Swap back to object form. The previous object-mode tracking was
80
+ // reset by the string assignment, so we start fresh and must not
81
+ // carry forward stale keys from the initial object.
82
+ style.set({ margin: '5px' })
83
+ expect(el.style.margin).toBe('5px')
84
+ // Previously-set `background` from the string form stays put because
85
+ // cssText can't round-trip through key-level tracking — this is
86
+ // identical to React/Solid/Vue behavior; document it.
87
+ })
88
+ })
@@ -0,0 +1,81 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import { h } from '@pyreon/core'
3
+ import { signal } from '@pyreon/reactivity'
4
+ import { Transition as _Transition, mount } from '../index'
5
+
6
+ const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
7
+
8
+ function container(): HTMLElement {
9
+ const el = document.createElement('div')
10
+ document.body.appendChild(el)
11
+ return el
12
+ }
13
+
14
+ // Regression for the 5s safety-timer leak in applyEnter / applyLeave.
15
+ // Before the fix, when transitionend fired normally the 5s setTimeout was
16
+ // never cleared and would re-invoke `done()` later, firing onAfterEnter /
17
+ // onAfterLeave twice and leaking closures over the element.
18
+ describe('Transition — safety-timer leak (regression)', () => {
19
+ beforeEach(() => {
20
+ vi.useFakeTimers()
21
+ })
22
+ afterEach(() => {
23
+ vi.useRealTimers()
24
+ })
25
+
26
+ test('onAfterEnter fires exactly once when transitionend fires before 5s safety timeout', async () => {
27
+ const el = container()
28
+ const show = signal(false)
29
+ const onAfterEnter = vi.fn()
30
+
31
+ mount(
32
+ h(
33
+ Transition,
34
+ { name: 'fade', show: () => show(), onAfterEnter },
35
+ h('div', { class: 'enter-leak' }, 'x'),
36
+ ),
37
+ el,
38
+ )
39
+
40
+ show.set(true)
41
+ await vi.advanceTimersByTimeAsync(20)
42
+
43
+ const target = el.querySelector('.enter-leak') as HTMLElement
44
+ expect(target).not.toBeNull()
45
+
46
+ target.dispatchEvent(new Event('transitionend'))
47
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
48
+
49
+ // Advance past the safety timeout. With the leak, done() would fire again.
50
+ await vi.advanceTimersByTimeAsync(6000)
51
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
52
+ })
53
+
54
+ test('onAfterLeave fires exactly once when transitionend fires before 5s safety timeout', async () => {
55
+ const el = container()
56
+ const show = signal(true)
57
+ const onAfterLeave = vi.fn()
58
+
59
+ mount(
60
+ h(
61
+ Transition,
62
+ { name: 'fade', show: () => show(), onAfterLeave },
63
+ h('div', { class: 'leave-leak' }, 'x'),
64
+ ),
65
+ el,
66
+ )
67
+ await vi.advanceTimersByTimeAsync(20)
68
+
69
+ show.set(false)
70
+ await vi.advanceTimersByTimeAsync(20)
71
+
72
+ const target = el.querySelector('.leave-leak') as HTMLElement
73
+ expect(target).not.toBeNull()
74
+
75
+ target.dispatchEvent(new Event('transitionend'))
76
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
77
+
78
+ await vi.advanceTimersByTimeAsync(6000)
79
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
80
+ })
81
+ })
@@ -0,0 +1,56 @@
1
+ import { Fragment, h } from '@pyreon/core'
2
+ import { mount } from '../index'
3
+
4
+ // Lock-in tests for behaviors PR #235 investigated and claimed
5
+ // "verified correct". Without code assertions the prose claims
6
+ // could silently regress.
7
+
8
+ describe('Fragment + key — key is inert', () => {
9
+ let container: HTMLDivElement
10
+
11
+ beforeEach(() => {
12
+ container = document.createElement('div')
13
+ document.body.appendChild(container)
14
+ })
15
+
16
+ afterEach(() => {
17
+ container.remove()
18
+ })
19
+
20
+ it('Fragment with a key renders its children inline (key does not reconcile)', () => {
21
+ mount(
22
+ h(
23
+ Fragment,
24
+ { key: 'x' },
25
+ h('span', { id: 'a' }, 'a'),
26
+ h('span', { id: 'b' }, 'b'),
27
+ ),
28
+ container,
29
+ )
30
+
31
+ // Both children are present at the top level of the container —
32
+ // no extra wrapper, key didn't alter the structure.
33
+ expect(container.querySelector('#a')?.textContent).toBe('a')
34
+ expect(container.querySelector('#b')?.textContent).toBe('b')
35
+ expect(container.children).toHaveLength(2)
36
+ })
37
+
38
+ it('Fragment without a key renders identically', () => {
39
+ mount(
40
+ h(Fragment, null, h('span', { id: 'a' }, 'a'), h('span', { id: 'b' }, 'b')),
41
+ container,
42
+ )
43
+ expect(container.children).toHaveLength(2)
44
+ expect(container.querySelector('#a')?.textContent).toBe('a')
45
+ expect(container.querySelector('#b')?.textContent).toBe('b')
46
+ })
47
+ })
48
+
49
+ describe('Suspense fast-resolve — fallback-first streaming contract', () => {
50
+ // This is a SERVER behavior, not a browser one. The PR-#235 claim was:
51
+ // renderToStream always emits fallback first, then swap — even if the
52
+ // async child resolves synchronously. Synchronous callers should use
53
+ // renderToString. That contract is already locked in by the existing
54
+ // streaming integration tests; no additional coverage needed here.
55
+ it.skip('locked in by renderToStream integration tests — see runtime-server/src/tests/ssr.test.ts', () => {})
56
+ })
package/src/transition.ts CHANGED
@@ -93,17 +93,25 @@ export function Transition(props: TransitionProps): VNodeChild {
93
93
  requestAnimationFrame(() => {
94
94
  el.classList.remove(cls.ef)
95
95
  el.classList.add(cls.et)
96
+ let safetyTimer: ReturnType<typeof setTimeout> | null = null
96
97
  const done = () => {
97
98
  // Remove both listeners — only one fires, so clean up the other
98
99
  el.removeEventListener('transitionend', done)
99
100
  el.removeEventListener('animationend', done)
101
+ // Clear the safety timeout — without this, when transitionend fires
102
+ // normally the 5s timer would still fire later and re-invoke done(),
103
+ // leaking timer refs and re-firing onAfterEnter.
104
+ if (safetyTimer !== null) {
105
+ clearTimeout(safetyTimer)
106
+ safetyTimer = null
107
+ }
100
108
  el.classList.remove(cls.ea, cls.et)
101
109
  props.onAfterEnter?.(el)
102
110
  }
103
111
  el.addEventListener('transitionend', done, { once: true })
104
112
  el.addEventListener('animationend', done, { once: true })
105
113
  // Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
106
- setTimeout(done, 5000)
114
+ safetyTimer = setTimeout(done, 5000)
107
115
  })
108
116
  }
109
117
 
@@ -114,10 +122,16 @@ export function Transition(props: TransitionProps): VNodeChild {
114
122
  requestAnimationFrame(() => {
115
123
  el.classList.remove(cls.lf)
116
124
  el.classList.add(cls.lt)
125
+ let safetyTimer: ReturnType<typeof setTimeout> | null = null
117
126
  const done = () => {
118
127
  // Remove both listeners — only one fires, so clean up the other
119
128
  el.removeEventListener('transitionend', done)
120
129
  el.removeEventListener('animationend', done)
130
+ // Clear the safety timeout (see applyEnter for rationale).
131
+ if (safetyTimer !== null) {
132
+ clearTimeout(safetyTimer)
133
+ safetyTimer = null
134
+ }
121
135
  el.classList.remove(cls.la, cls.lt)
122
136
  pendingLeaveCancel = null
123
137
  isMounted.set(false)
@@ -126,12 +140,16 @@ export function Transition(props: TransitionProps): VNodeChild {
126
140
  pendingLeaveCancel = () => {
127
141
  el.removeEventListener('transitionend', done)
128
142
  el.removeEventListener('animationend', done)
143
+ if (safetyTimer !== null) {
144
+ clearTimeout(safetyTimer)
145
+ safetyTimer = null
146
+ }
129
147
  el.classList.remove(cls.lf, cls.la, cls.lt)
130
148
  }
131
149
  el.addEventListener('transitionend', done, { once: true })
132
150
  el.addEventListener('animationend', done, { once: true })
133
151
  // Safety timeout: if CSS animation never fires, force cleanup
134
- setTimeout(done, 5000)
152
+ safetyTimer = setTimeout(done, 5000)
135
153
  })
136
154
  }
137
155