@pyreon/runtime-dom 0.24.5 → 0.24.6
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/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { For, h, Show } from '@pyreon/core'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
-
import { describe, expect, it } from 'vitest'
|
|
5
|
-
|
|
6
|
-
// CONTRACT — bug surfaced by `scripts/leak-sweep.ts` against the
|
|
7
|
-
// `domConditionalToggle-1000` journey; now fixed.
|
|
8
|
-
//
|
|
9
|
-
// **Bug shape (pre-fix):**
|
|
10
|
-
//
|
|
11
|
-
// [pyreon] Unhandled effect error: NotFoundError: Failed to execute
|
|
12
|
-
// 'insertBefore' on 'Node': The node before which the new node is
|
|
13
|
-
// to be inserted is not a child of this node.
|
|
14
|
-
// at mountReactive (...)
|
|
15
|
-
// at mountChild (...)
|
|
16
|
-
//
|
|
17
|
-
// Trigger: N `<Show when={signal[i]}>` components inside a `<For>`,
|
|
18
|
-
// then batched signal writes flip every `signal[i]` false → true → false.
|
|
19
|
-
//
|
|
20
|
-
// **Root cause:** `mountReactive` captured `parent` in its setup closure.
|
|
21
|
-
// `mountFor` creates child DOM into a DocumentFragment, then moves the
|
|
22
|
-
// fragment contents to the live parent via
|
|
23
|
-
// `liveParent.insertBefore(frag, tailMarker)`. After the move, every
|
|
24
|
-
// `mountReactive` (e.g. the one created when Show's `when` accessor
|
|
25
|
-
// returned the function child) had a stale `parent` reference pointing
|
|
26
|
-
// at the now-empty fragment, while its marker had been carried with the
|
|
27
|
-
// fragment's contents to `liveParent`. On the next signal flip, the
|
|
28
|
-
// effect re-ran and called `parent.insertBefore(node, marker)` against
|
|
29
|
-
// the stale fragment, throwing NotFoundError because `marker` was no
|
|
30
|
-
// longer a child of `parent`. The throw landed in Pyreon's
|
|
31
|
-
// "unhandled effect error" path → console.error + loss of For's
|
|
32
|
-
// children from the DOM (final count dropped to 0).
|
|
33
|
-
//
|
|
34
|
-
// **Fix:** `mountReactive` now reads `marker.parentNode` at each effect
|
|
35
|
-
// run (with the closure-captured `parent` as a detached-marker
|
|
36
|
-
// fallback). The marker is moved by the same `insertBefore(frag, ...)`
|
|
37
|
-
// as the rest of the fragment contents, so its live `parentNode` is
|
|
38
|
-
// always the correct live parent.
|
|
39
|
-
//
|
|
40
|
-
// Reproducer: `bun run perf:leak-sweep --app perf-dashboard --journeys domConditionalToggle-1000`
|
|
41
|
-
|
|
42
|
-
describe('mountReactive: <Show> inside <For> under batched signal toggles', () => {
|
|
43
|
-
it('single <Show> with function-child handles toggle cycles correctly (sanity, works today)', async () => {
|
|
44
|
-
// 1 Show, 1 signal. Should always work — no For wrapper, no batched
|
|
45
|
-
// multi-signal flush. Proves the bug is specifically the For-of-Show
|
|
46
|
-
// interaction, not Show itself.
|
|
47
|
-
const flag = signal<boolean>(true)
|
|
48
|
-
const { container, unmount } = mountInBrowser(
|
|
49
|
-
h(
|
|
50
|
-
'div',
|
|
51
|
-
{ id: 'root' },
|
|
52
|
-
h(Show, {
|
|
53
|
-
when: () => flag(),
|
|
54
|
-
children: () => h('div', { 'data-id': '0' }, 'Visible'),
|
|
55
|
-
}),
|
|
56
|
-
),
|
|
57
|
-
)
|
|
58
|
-
await flush()
|
|
59
|
-
expect(container.querySelectorAll('div[data-id]')).toHaveLength(1)
|
|
60
|
-
flag.set(false)
|
|
61
|
-
await flush()
|
|
62
|
-
expect(container.querySelectorAll('div[data-id]')).toHaveLength(0)
|
|
63
|
-
flag.set(true)
|
|
64
|
-
await flush()
|
|
65
|
-
expect(container.querySelectorAll('div[data-id]')).toHaveLength(1)
|
|
66
|
-
unmount()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it(
|
|
70
|
-
'CONTRACT: <For> + <Show> mass-toggle does not throw NotFoundError or lose children',
|
|
71
|
-
async () => {
|
|
72
|
-
// 100 Show items inside a For. Each Show's `when` is its own signal.
|
|
73
|
-
// The function child `{() => <div/>}` exercises the function-child
|
|
74
|
-
// mountReactive path that's the actual failure site.
|
|
75
|
-
const flags = Array.from({ length: 100 }, () => signal<boolean>(true))
|
|
76
|
-
const indices = Array.from({ length: 100 }, (_, i) => i)
|
|
77
|
-
|
|
78
|
-
const { container, unmount } = mountInBrowser(
|
|
79
|
-
h(
|
|
80
|
-
'div',
|
|
81
|
-
{ id: 'root' },
|
|
82
|
-
For({
|
|
83
|
-
each: indices,
|
|
84
|
-
by: (i: number) => i,
|
|
85
|
-
children: (i: number) =>
|
|
86
|
-
h(
|
|
87
|
-
Show,
|
|
88
|
-
{
|
|
89
|
-
when: () => (flags[i] as ReturnType<typeof signal<boolean>>)(),
|
|
90
|
-
children: () => h('div', { 'data-id': String(i) }, `Visible ${i}`),
|
|
91
|
-
},
|
|
92
|
-
),
|
|
93
|
-
}),
|
|
94
|
-
),
|
|
95
|
-
)
|
|
96
|
-
await flush()
|
|
97
|
-
try {
|
|
98
|
-
// Sanity: all 100 visible at mount.
|
|
99
|
-
expect(container.querySelectorAll('div[data-id]')).toHaveLength(100)
|
|
100
|
-
|
|
101
|
-
// ONE mass-toggle cycle: false → true.
|
|
102
|
-
for (const f of flags) f.set(false)
|
|
103
|
-
await flush()
|
|
104
|
-
expect(container.querySelectorAll('div[data-id]')).toHaveLength(0)
|
|
105
|
-
|
|
106
|
-
for (const f of flags) f.set(true)
|
|
107
|
-
await flush()
|
|
108
|
-
|
|
109
|
-
// **Bug fires here**: pre-fix the framework throws NotFoundError
|
|
110
|
-
// inside mountReactive's setup, the entire reactive subtree is
|
|
111
|
-
// lost, and the count drops to 0. The CONTRACT assertion is
|
|
112
|
-
// "should be 100"; today the framework returns 0, so `it.fails`
|
|
113
|
-
// is the correct marker. When the real fix lands, this
|
|
114
|
-
// assertion will pass and the test will fail — signal to flip
|
|
115
|
-
// the marker.
|
|
116
|
-
expect(container.querySelectorAll('div[data-id]')).toHaveLength(100)
|
|
117
|
-
} finally {
|
|
118
|
-
unmount()
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
)
|
|
122
|
-
})
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
})
|