@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.
- package/README.md +13 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +167 -26
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +12 -6
- package/src/hydrate.ts +35 -5
- package/src/mount.ts +8 -2
- package/src/props.ts +101 -22
- package/src/tests/callback-ref-unmount.browser.test.ts +62 -0
- package/src/tests/callback-ref-unmount.test.ts +52 -0
- package/src/tests/dev-gate-pattern.test.ts +40 -0
- package/src/tests/dev-gate-treeshake.test.ts +262 -0
- package/src/tests/mount.test.ts +95 -5
- package/src/tests/props.test.ts +117 -0
- package/src/tests/runtime-dom.browser.test.ts +295 -0
- package/src/tests/ssr-xss-round-trip.browser.test.ts +93 -0
- package/src/tests/style-key-removal.browser.test.ts +54 -0
- package/src/tests/style-key-removal.test.ts +88 -0
- package/src/tests/transition-timeout-leak.test.ts +126 -0
- package/src/tests/verified-correct-probes.test.ts +56 -0
- package/src/transition-group.ts +80 -8
- package/src/transition.ts +46 -3
|
@@ -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-group.ts
CHANGED
|
@@ -41,6 +41,15 @@ type ItemEntry = {
|
|
|
41
41
|
ref: ReturnType<typeof createRef<HTMLElement>>
|
|
42
42
|
cleanup: () => void
|
|
43
43
|
leaving: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Cancel function for an in-progress enter / leave / move transition —
|
|
46
|
+
* removes listeners, clears the safety timer, strips active-state
|
|
47
|
+
* classes, but does NOT fire the onAfterX callback. Called when a
|
|
48
|
+
* transition is superseded or when the whole TransitionGroup unmounts
|
|
49
|
+
* mid-transition (so onAfterEnter/Leave doesn't fire on a detached
|
|
50
|
+
* element and the 5s timer doesn't leak past unmount).
|
|
51
|
+
*/
|
|
52
|
+
cancelTransition: (() => void) | null
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
/**
|
|
@@ -87,40 +96,80 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
87
96
|
const ready = signal(false)
|
|
88
97
|
let firstRun = true
|
|
89
98
|
|
|
90
|
-
const applyEnter = (el: HTMLElement) => {
|
|
99
|
+
const applyEnter = (entry: ItemEntry, el: HTMLElement) => {
|
|
91
100
|
props.onBeforeEnter?.(el)
|
|
92
101
|
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
93
102
|
el.classList.add(cls.ef, cls.ea)
|
|
94
103
|
requestAnimationFrame(() => {
|
|
95
104
|
el.classList.remove(cls.ef)
|
|
96
105
|
el.classList.add(cls.et)
|
|
106
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
97
107
|
const done = () => {
|
|
98
108
|
el.removeEventListener('transitionend', done)
|
|
99
109
|
el.removeEventListener('animationend', done)
|
|
110
|
+
if (safetyTimer !== null) {
|
|
111
|
+
clearTimeout(safetyTimer)
|
|
112
|
+
safetyTimer = null
|
|
113
|
+
}
|
|
114
|
+
entry.cancelTransition = null
|
|
100
115
|
el.classList.remove(cls.ea, cls.et)
|
|
101
116
|
props.onAfterEnter?.(el)
|
|
102
117
|
}
|
|
118
|
+
entry.cancelTransition = () => {
|
|
119
|
+
el.removeEventListener('transitionend', done)
|
|
120
|
+
el.removeEventListener('animationend', done)
|
|
121
|
+
if (safetyTimer !== null) {
|
|
122
|
+
clearTimeout(safetyTimer)
|
|
123
|
+
safetyTimer = null
|
|
124
|
+
}
|
|
125
|
+
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
126
|
+
}
|
|
103
127
|
el.addEventListener('transitionend', done, { once: true })
|
|
104
128
|
el.addEventListener('animationend', done, { once: true })
|
|
129
|
+
// Safety timeout: if CSS animation never fires (off-screen, zero
|
|
130
|
+
// duration, `display: none`), force cleanup so the entry's
|
|
131
|
+
// onAfterEnter runs and the listener + closure don't leak.
|
|
132
|
+
safetyTimer = setTimeout(done, 5000)
|
|
105
133
|
})
|
|
106
134
|
}
|
|
107
135
|
|
|
108
|
-
const applyLeave = (el: HTMLElement, onDone: () => void) => {
|
|
136
|
+
const applyLeave = (entry: ItemEntry, el: HTMLElement, onDone: () => void) => {
|
|
109
137
|
props.onBeforeLeave?.(el)
|
|
110
138
|
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
111
139
|
el.classList.add(cls.lf, cls.la)
|
|
112
140
|
requestAnimationFrame(() => {
|
|
113
141
|
el.classList.remove(cls.lf)
|
|
114
142
|
el.classList.add(cls.lt)
|
|
143
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
115
144
|
const done = () => {
|
|
116
145
|
el.removeEventListener('transitionend', done)
|
|
117
146
|
el.removeEventListener('animationend', done)
|
|
147
|
+
if (safetyTimer !== null) {
|
|
148
|
+
clearTimeout(safetyTimer)
|
|
149
|
+
safetyTimer = null
|
|
150
|
+
}
|
|
151
|
+
entry.cancelTransition = null
|
|
118
152
|
el.classList.remove(cls.la, cls.lt)
|
|
119
153
|
props.onAfterLeave?.(el)
|
|
120
154
|
onDone()
|
|
121
155
|
}
|
|
156
|
+
entry.cancelTransition = () => {
|
|
157
|
+
el.removeEventListener('transitionend', done)
|
|
158
|
+
el.removeEventListener('animationend', done)
|
|
159
|
+
if (safetyTimer !== null) {
|
|
160
|
+
clearTimeout(safetyTimer)
|
|
161
|
+
safetyTimer = null
|
|
162
|
+
}
|
|
163
|
+
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
164
|
+
}
|
|
122
165
|
el.addEventListener('transitionend', done, { once: true })
|
|
123
166
|
el.addEventListener('animationend', done, { once: true })
|
|
167
|
+
// Safety timeout: CRITICAL for transition-group. Without it, a list
|
|
168
|
+
// item whose leave transition never fires (off-screen, zero
|
|
169
|
+
// duration, `display: none`) stays in the `entries` Map forever
|
|
170
|
+
// because `onDone` never runs to `entries.delete(key)` — a real
|
|
171
|
+
// memory leak that grows with every list mutation.
|
|
172
|
+
safetyTimer = setTimeout(done, 5000)
|
|
124
173
|
})
|
|
125
174
|
}
|
|
126
175
|
|
|
@@ -131,7 +180,7 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
131
180
|
entry.leaving = true
|
|
132
181
|
const el = entry.ref.current
|
|
133
182
|
if (el) {
|
|
134
|
-
applyLeave(el, () => {
|
|
183
|
+
applyLeave(entry, el, () => {
|
|
135
184
|
entry.cleanup()
|
|
136
185
|
entries.delete(key)
|
|
137
186
|
})
|
|
@@ -156,25 +205,41 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
156
205
|
? { ...rawVNode, props: { ...rawVNode.props, ref: itemRef } as Props }
|
|
157
206
|
: rawVNode
|
|
158
207
|
const cleanup = mountChild(vnode, container, null)
|
|
159
|
-
const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false }
|
|
208
|
+
const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false, cancelTransition: null }
|
|
160
209
|
entries.set(key, entry)
|
|
161
210
|
newEntries.push(entry)
|
|
162
211
|
}
|
|
163
212
|
return newEntries
|
|
164
213
|
}
|
|
165
214
|
|
|
166
|
-
const startMoveAnimation = (el: HTMLElement) => {
|
|
215
|
+
const startMoveAnimation = (entry: ItemEntry, el: HTMLElement) => {
|
|
167
216
|
requestAnimationFrame(() => {
|
|
168
217
|
el.classList.add(cls.mv)
|
|
169
218
|
el.style.transform = ''
|
|
170
219
|
el.style.transition = ''
|
|
220
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
171
221
|
const done = () => {
|
|
172
222
|
el.removeEventListener('transitionend', done)
|
|
173
223
|
el.removeEventListener('animationend', done)
|
|
224
|
+
if (safetyTimer !== null) {
|
|
225
|
+
clearTimeout(safetyTimer)
|
|
226
|
+
safetyTimer = null
|
|
227
|
+
}
|
|
228
|
+
entry.cancelTransition = null
|
|
229
|
+
el.classList.remove(cls.mv)
|
|
230
|
+
}
|
|
231
|
+
entry.cancelTransition = () => {
|
|
232
|
+
el.removeEventListener('transitionend', done)
|
|
233
|
+
el.removeEventListener('animationend', done)
|
|
234
|
+
if (safetyTimer !== null) {
|
|
235
|
+
clearTimeout(safetyTimer)
|
|
236
|
+
safetyTimer = null
|
|
237
|
+
}
|
|
174
238
|
el.classList.remove(cls.mv)
|
|
175
239
|
}
|
|
176
240
|
el.addEventListener('transitionend', done, { once: true })
|
|
177
241
|
el.addEventListener('animationend', done, { once: true })
|
|
242
|
+
safetyTimer = setTimeout(done, 5000)
|
|
178
243
|
})
|
|
179
244
|
}
|
|
180
245
|
|
|
@@ -187,7 +252,7 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
187
252
|
const el = entry.ref.current
|
|
188
253
|
el.style.transform = `translate(${dx}px, ${dy}px)`
|
|
189
254
|
el.style.transition = 'none'
|
|
190
|
-
startMoveAnimation(el)
|
|
255
|
+
startMoveAnimation(entry, el)
|
|
191
256
|
}
|
|
192
257
|
|
|
193
258
|
/** Apply FLIP move animations for items that shifted position. */
|
|
@@ -224,7 +289,7 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
224
289
|
const animateNewEntries = (newEntries: ItemEntry[]) => {
|
|
225
290
|
for (const entry of newEntries) {
|
|
226
291
|
queueMicrotask(() => {
|
|
227
|
-
if (entry.ref.current) applyEnter(entry.ref.current)
|
|
292
|
+
if (entry.ref.current) applyEnter(entry, entry.ref.current)
|
|
228
293
|
})
|
|
229
294
|
}
|
|
230
295
|
}
|
|
@@ -255,7 +320,14 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
255
320
|
|
|
256
321
|
onUnmount(() => {
|
|
257
322
|
e.dispose()
|
|
258
|
-
for (const entry of entries.values())
|
|
323
|
+
for (const entry of entries.values()) {
|
|
324
|
+
// Cancel any in-progress enter/leave/move transition so the 5s
|
|
325
|
+
// safety timer doesn't keep running past container unmount and
|
|
326
|
+
// onAfterEnter / onAfterLeave don't fire on a detached element.
|
|
327
|
+
entry.cancelTransition?.()
|
|
328
|
+
entry.cancelTransition = null
|
|
329
|
+
entry.cleanup()
|
|
330
|
+
}
|
|
259
331
|
entries.clear()
|
|
260
332
|
})
|
|
261
333
|
|
package/src/transition.ts
CHANGED
|
@@ -80,44 +80,79 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
80
80
|
const ref = createRef<HTMLElement>()
|
|
81
81
|
const isMounted = signal(runUntracked<boolean>(props.show))
|
|
82
82
|
|
|
83
|
-
// Cancel
|
|
83
|
+
// Cancel in-progress enter / leave when the component unmounts or when a
|
|
84
|
+
// new transition supersedes the current one. Both are set inside their
|
|
85
|
+
// respective applyX(). Calling the cancel removes event listeners, clears
|
|
86
|
+
// the safety timer, and strips active-state classes — WITHOUT firing the
|
|
87
|
+
// onAfterX callback (which would run on a detached element after unmount).
|
|
88
|
+
let pendingEnterCancel: (() => void) | null = null
|
|
84
89
|
let pendingLeaveCancel: (() => void) | null = null
|
|
85
90
|
let initialized = false
|
|
86
91
|
|
|
87
92
|
const applyEnter = (el: HTMLElement) => {
|
|
88
93
|
pendingLeaveCancel?.()
|
|
89
94
|
pendingLeaveCancel = null
|
|
95
|
+
pendingEnterCancel?.()
|
|
96
|
+
pendingEnterCancel = null
|
|
90
97
|
props.onBeforeEnter?.(el)
|
|
91
98
|
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
92
99
|
el.classList.add(cls.ef, cls.ea)
|
|
93
100
|
requestAnimationFrame(() => {
|
|
94
101
|
el.classList.remove(cls.ef)
|
|
95
102
|
el.classList.add(cls.et)
|
|
103
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
96
104
|
const done = () => {
|
|
97
105
|
// Remove both listeners — only one fires, so clean up the other
|
|
98
106
|
el.removeEventListener('transitionend', done)
|
|
99
107
|
el.removeEventListener('animationend', done)
|
|
108
|
+
// Clear the safety timeout — without this, when transitionend fires
|
|
109
|
+
// normally the 5s timer would still fire later and re-invoke done(),
|
|
110
|
+
// leaking timer refs and re-firing onAfterEnter.
|
|
111
|
+
if (safetyTimer !== null) {
|
|
112
|
+
clearTimeout(safetyTimer)
|
|
113
|
+
safetyTimer = null
|
|
114
|
+
}
|
|
115
|
+
pendingEnterCancel = null
|
|
100
116
|
el.classList.remove(cls.ea, cls.et)
|
|
101
117
|
props.onAfterEnter?.(el)
|
|
102
118
|
}
|
|
119
|
+
// Cancel path (called from onUnmount or a superseding transition): tears
|
|
120
|
+
// down without firing onAfterEnter on a detached element.
|
|
121
|
+
pendingEnterCancel = () => {
|
|
122
|
+
el.removeEventListener('transitionend', done)
|
|
123
|
+
el.removeEventListener('animationend', done)
|
|
124
|
+
if (safetyTimer !== null) {
|
|
125
|
+
clearTimeout(safetyTimer)
|
|
126
|
+
safetyTimer = null
|
|
127
|
+
}
|
|
128
|
+
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
129
|
+
}
|
|
103
130
|
el.addEventListener('transitionend', done, { once: true })
|
|
104
131
|
el.addEventListener('animationend', done, { once: true })
|
|
105
132
|
// Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
|
|
106
|
-
setTimeout(done, 5000)
|
|
133
|
+
safetyTimer = setTimeout(done, 5000)
|
|
107
134
|
})
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
const applyLeave = (el: HTMLElement) => {
|
|
138
|
+
pendingEnterCancel?.()
|
|
139
|
+
pendingEnterCancel = null
|
|
111
140
|
props.onBeforeLeave?.(el)
|
|
112
141
|
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
113
142
|
el.classList.add(cls.lf, cls.la)
|
|
114
143
|
requestAnimationFrame(() => {
|
|
115
144
|
el.classList.remove(cls.lf)
|
|
116
145
|
el.classList.add(cls.lt)
|
|
146
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
117
147
|
const done = () => {
|
|
118
148
|
// Remove both listeners — only one fires, so clean up the other
|
|
119
149
|
el.removeEventListener('transitionend', done)
|
|
120
150
|
el.removeEventListener('animationend', done)
|
|
151
|
+
// Clear the safety timeout (see applyEnter for rationale).
|
|
152
|
+
if (safetyTimer !== null) {
|
|
153
|
+
clearTimeout(safetyTimer)
|
|
154
|
+
safetyTimer = null
|
|
155
|
+
}
|
|
121
156
|
el.classList.remove(cls.la, cls.lt)
|
|
122
157
|
pendingLeaveCancel = null
|
|
123
158
|
isMounted.set(false)
|
|
@@ -126,12 +161,16 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
126
161
|
pendingLeaveCancel = () => {
|
|
127
162
|
el.removeEventListener('transitionend', done)
|
|
128
163
|
el.removeEventListener('animationend', done)
|
|
164
|
+
if (safetyTimer !== null) {
|
|
165
|
+
clearTimeout(safetyTimer)
|
|
166
|
+
safetyTimer = null
|
|
167
|
+
}
|
|
129
168
|
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
130
169
|
}
|
|
131
170
|
el.addEventListener('transitionend', done, { once: true })
|
|
132
171
|
el.addEventListener('animationend', done, { once: true })
|
|
133
172
|
// Safety timeout: if CSS animation never fires, force cleanup
|
|
134
|
-
setTimeout(done, 5000)
|
|
173
|
+
safetyTimer = setTimeout(done, 5000)
|
|
135
174
|
})
|
|
136
175
|
}
|
|
137
176
|
|
|
@@ -163,6 +202,10 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
163
202
|
})
|
|
164
203
|
|
|
165
204
|
onUnmount(() => {
|
|
205
|
+
// Cancel both pending transitions so neither fires its onAfterX
|
|
206
|
+
// callback on a now-detached element after the 5s safety window.
|
|
207
|
+
pendingEnterCancel?.()
|
|
208
|
+
pendingEnterCancel = null
|
|
166
209
|
pendingLeaveCancel?.()
|
|
167
210
|
pendingLeaveCancel = null
|
|
168
211
|
})
|