@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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. 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
- })