@pyreon/runtime-dom 0.12.14 → 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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +105 -16
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/props.ts +58 -19
- package/src/tests/mount.test.ts +89 -0
- package/src/tests/props.test.ts +117 -0
- package/src/tests/transition-timeout-leak.test.ts +45 -0
- package/src/transition-group.ts +80 -8
- package/src/transition.ts +26 -1
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/delegate.ts","../../../src/devtools.ts","../../../src/hydrate.ts","../../../src/hydration-debug.ts","../../../src/keep-alive.ts","../../../src/mount.ts","../../../src/props.ts","../../../src/template.ts","../../../src/transition.ts","../../../src/transition-group.ts","../../../src/index.ts"],"mappings":";;;;;;AAoBA;;;;;AA8BA;;;;;AAWA;;;;cAzCa,gBAAA,EAAgB,GAAA;;;;ACJ7B;iBDkCgB,iBAAA,CAAkB,SAAA;;;;;iBAWlB,eAAA,CAAgB,SAAA,EAAW,OAAA;;;;;;AAzC3C;;;;;AA8BA;;;;;AAWA;;UC7CiB,sBAAA;EACf,EAAA;EACA,IAAA;;EAEA,EAAA,EAAI,OAAA;EACJ,QAAA;EACA,QAAA;AAAA;AAAA,UAGe,cAAA;EAAA,SACN,OAAA;EACT,gBAAA,IAAoB,sBAAA;EACpB,gBAAA,IAAoB,sBAAA;EACpB,SAAA,CAAU,EAAA;EACV,gBAAA,CAAiB,EAAA,GAAK,KAAA,EAAO,sBAAA;EAC7B,kBAAA,CAAmB,EAAA,GAAK,EAAA;EATxB;EAWA,aAAA;EACA,cAAA;AAAA;;;;;;AAlBF;;;;;;;;;;;iBCmagB,WAAA,CAAY,SAAA,EAAW,OAAA,EAAS,KAAA,EAAO,UAAA;;;;;;AF/ZvD;;;;;AA8BA;;iBGhCgB,uBAAA,CAAA;AAAA,iBAIA,wBAAA,CAAA;;;UCjBC,cAAA,SAAuB,KAAA;;AJexC;;;;;EIRE,MAAA;EACA,QAAA,GAAW,UAAA;AAAA;;;AJgDb;;;;;;;;AC7CA;;;;;;;;;;;;AASA;;iBGegB,SAAA,CAAU,KAAA,EAAO,cAAA,GAAiB,UAAA;;;KCV7C,SAAA;;ALVL;;;;;AA8BA;iBKCgB,UAAA,CACd,KAAA,EAAO,UAAA,GAAa,UAAA,YAAsB,UAAA,GAAa,UAAA,KACvD,MAAA,EAAQ,IAAA,EACR,MAAA,GAAQ,IAAA,UACP,SAAA;;;KCjDE,OAAA;AAAA,KASO,UAAA,IAAc,IAAA;ANK1B;;;;;AA8BA;;;;;AAWA;;;;;;AAzCA,iBMegB,YAAA,CAAa,EAAA,EAAI,UAAA;;ALnBjC;;;iBK8IgB,YAAA,CAAa,IAAA;;;;;;iBAgBb,UAAA,CAAW,EAAA,EAAI,OAAA,EAAS,KAAA,EAAO,KAAA,GAAQ,OAAA;AAAA,
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/delegate.ts","../../../src/devtools.ts","../../../src/hydrate.ts","../../../src/hydration-debug.ts","../../../src/keep-alive.ts","../../../src/mount.ts","../../../src/props.ts","../../../src/template.ts","../../../src/transition.ts","../../../src/transition-group.ts","../../../src/index.ts"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.15",
|
|
4
4
|
"description": "DOM renderer for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"prepublishOnly": "bun run build"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@pyreon/core": "^0.12.
|
|
47
|
-
"@pyreon/reactivity": "^0.12.
|
|
46
|
+
"@pyreon/core": "^0.12.15",
|
|
47
|
+
"@pyreon/reactivity": "^0.12.15"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
51
|
-
"@pyreon/compiler": "^0.12.
|
|
52
|
-
"@pyreon/runtime-server": "^0.12.
|
|
51
|
+
"@pyreon/compiler": "^0.12.15",
|
|
52
|
+
"@pyreon/runtime-server": "^0.12.15",
|
|
53
53
|
"@pyreon/test-utils": "^0.12.10",
|
|
54
54
|
"@vitest/browser-playwright": "^4.1.4",
|
|
55
55
|
"esbuild": "^0.28.0",
|
package/src/props.ts
CHANGED
|
@@ -245,37 +245,76 @@ function applyEventProp(el: Element, key: string, value: unknown): Cleanup | nul
|
|
|
245
245
|
return () => el.removeEventListener(eventName, batched)
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Sink for a single prop's CALLED value (always a primitive / object /
|
|
250
|
+
* `null` — never a function). Called both directly for static values and
|
|
251
|
+
* from the reactive `renderEffect` for accessor-bound values.
|
|
252
|
+
*
|
|
253
|
+
* NOTE on architecture: extracting the special-cased sinks
|
|
254
|
+
* (`innerHTML` / `dangerouslySetInnerHTML`) into this single dispatch
|
|
255
|
+
* function ensures every prop kind goes through the same reactive
|
|
256
|
+
* wrapping at `applyProp`'s entry. Previously each special case had its
|
|
257
|
+
* own early-return branch that needed to remember to handle function
|
|
258
|
+
* values; missing the dance once meant the closure was stringified and
|
|
259
|
+
* set as literal text. The structural fix (one reactive-wrap, then
|
|
260
|
+
* dispatch) eliminates the entire bug class.
|
|
261
|
+
*/
|
|
262
|
+
function applyStaticProp(el: Element, key: string, value: unknown): void {
|
|
263
|
+
if (__DEV__ && typeof value === 'function') {
|
|
264
|
+
// Defensive: function values must be unwrapped via `renderEffect`
|
|
265
|
+
// before reaching here. If we see one, a NEW special-case branch
|
|
266
|
+
// somewhere upstream skipped the reactive-wrapping dance — exactly
|
|
267
|
+
// the bug class the structural refactor was meant to eliminate.
|
|
268
|
+
console.warn(
|
|
269
|
+
`[Pyreon] applyStaticProp received a function for "${key}". ` +
|
|
270
|
+
`This likely means a new special-cased prop sink in applyProp() ` +
|
|
271
|
+
`bypassed the reactive-wrap path. The closure would be stringified ` +
|
|
272
|
+
`and set as a literal value. Verify the dispatch in applyProp().`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
251
275
|
|
|
252
|
-
// innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer
|
|
276
|
+
// innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer.
|
|
253
277
|
if (key === 'innerHTML') {
|
|
278
|
+
const html = String(value ?? '')
|
|
254
279
|
if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === 'function') {
|
|
255
|
-
;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(
|
|
280
|
+
;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(html)
|
|
256
281
|
} else {
|
|
257
|
-
;(el as HTMLElement).innerHTML = sanitizeHtml(
|
|
282
|
+
;(el as HTMLElement).innerHTML = sanitizeHtml(html)
|
|
258
283
|
}
|
|
259
|
-
return
|
|
284
|
+
return
|
|
260
285
|
}
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
286
|
+
|
|
287
|
+
// dangerouslySetInnerHTML — intentionally raw, developer owns sanitization
|
|
288
|
+
// (same as React). The name itself is the warning — React doesn't log,
|
|
289
|
+
// neither should we.
|
|
265
290
|
if (key === 'dangerouslySetInnerHTML') {
|
|
266
|
-
|
|
267
|
-
|
|
291
|
+
const v = value as { __html: string } | null | undefined
|
|
292
|
+
;(el as HTMLElement).innerHTML = v?.__html ?? ''
|
|
293
|
+
return
|
|
268
294
|
}
|
|
269
295
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
296
|
+
setStaticProp(el, key, value)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
300
|
+
// Event listener: onClick → "click"
|
|
301
|
+
if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
|
|
302
|
+
|
|
303
|
+
// Reactive prop — function value is an accessor closure. The JSX compiler
|
|
304
|
+
// emits `prop={someExpr(signal())}` as a `() => someExpr(signal())` thunk
|
|
305
|
+
// so the prop tracks the signal automatically. We wrap in `renderEffect`
|
|
306
|
+
// ONCE here, before any prop-kind dispatch, so EVERY sink gets the same
|
|
307
|
+
// reactive treatment. Previously special-cased sinks (innerHTML etc.) had
|
|
308
|
+
// early-return branches that bypassed this wrap and stringified the
|
|
309
|
+
// closure — the bug fixed by this restructure.
|
|
310
|
+
//
|
|
311
|
+
// Uses renderEffect (lighter than effect — no scope registration, no
|
|
312
|
+
// WeakMap) since lifecycle is managed by mountElement's cleanup array.
|
|
273
313
|
if (typeof value === 'function') {
|
|
274
|
-
|
|
275
|
-
return dispose
|
|
314
|
+
return renderEffect(() => applyStaticProp(el, key, (value as () => unknown)()))
|
|
276
315
|
}
|
|
277
316
|
|
|
278
|
-
|
|
317
|
+
applyStaticProp(el, key, value)
|
|
279
318
|
return null
|
|
280
319
|
}
|
|
281
320
|
|
package/src/tests/mount.test.ts
CHANGED
|
@@ -3177,6 +3177,95 @@ describe('TransitionGroup — cleanup', () => {
|
|
|
3177
3177
|
})
|
|
3178
3178
|
})
|
|
3179
3179
|
|
|
3180
|
+
// ─── TransitionGroup — leak regression tests ─────────────────────────────────
|
|
3181
|
+
// Regression for the two fixes:
|
|
3182
|
+
// 1. No safety timeout on applyLeave meant an item whose transition never
|
|
3183
|
+
// fired stayed in the `entries` Map forever (`entries.delete(key)` was
|
|
3184
|
+
// gated on the `done` callback firing).
|
|
3185
|
+
// 2. Unmount during in-flight transition left the 5s safety timer running,
|
|
3186
|
+
// firing `onAfterEnter` / `onAfterLeave` on detached elements.
|
|
3187
|
+
|
|
3188
|
+
describe('TransitionGroup — leak regressions', () => {
|
|
3189
|
+
beforeEach(() => {
|
|
3190
|
+
vi.useFakeTimers()
|
|
3191
|
+
})
|
|
3192
|
+
afterEach(() => {
|
|
3193
|
+
vi.useRealTimers()
|
|
3194
|
+
})
|
|
3195
|
+
|
|
3196
|
+
test('onAfterLeave fires via 5s safety timeout when transitionend never fires', async () => {
|
|
3197
|
+
const el = container()
|
|
3198
|
+
const items = signal([{ id: 1 }, { id: 2 }])
|
|
3199
|
+
const onAfterLeave = vi.fn()
|
|
3200
|
+
mount(
|
|
3201
|
+
h(TransitionGroup, {
|
|
3202
|
+
tag: 'div',
|
|
3203
|
+
name: 'fade',
|
|
3204
|
+
items,
|
|
3205
|
+
keyFn: (item: { id: number }) => item.id,
|
|
3206
|
+
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3207
|
+
onAfterLeave,
|
|
3208
|
+
}),
|
|
3209
|
+
el,
|
|
3210
|
+
)
|
|
3211
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3212
|
+
items.set([{ id: 1 }])
|
|
3213
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3214
|
+
// transitionend never fires — before the fix this would leak forever.
|
|
3215
|
+
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
3216
|
+
await vi.advanceTimersByTimeAsync(5100)
|
|
3217
|
+
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
3218
|
+
})
|
|
3219
|
+
|
|
3220
|
+
test('onAfterEnter does NOT fire after container unmount during in-flight enter', async () => {
|
|
3221
|
+
const el = container()
|
|
3222
|
+
const items = signal<{ id: number }[]>([])
|
|
3223
|
+
const onAfterEnter = vi.fn()
|
|
3224
|
+
const dispose = mount(
|
|
3225
|
+
h(TransitionGroup, {
|
|
3226
|
+
tag: 'div',
|
|
3227
|
+
name: 'fade',
|
|
3228
|
+
items,
|
|
3229
|
+
keyFn: (item: { id: number }) => item.id,
|
|
3230
|
+
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3231
|
+
onAfterEnter,
|
|
3232
|
+
}),
|
|
3233
|
+
el,
|
|
3234
|
+
)
|
|
3235
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3236
|
+
items.set([{ id: 1 }])
|
|
3237
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3238
|
+
// Mid-transition — unmount. The 5s safety timer must NOT fire the
|
|
3239
|
+
// callback on a detached element.
|
|
3240
|
+
dispose()
|
|
3241
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
3242
|
+
expect(onAfterEnter).not.toHaveBeenCalled()
|
|
3243
|
+
})
|
|
3244
|
+
|
|
3245
|
+
test('onAfterLeave does NOT fire after container unmount during in-flight leave', async () => {
|
|
3246
|
+
const el = container()
|
|
3247
|
+
const items = signal([{ id: 1 }])
|
|
3248
|
+
const onAfterLeave = vi.fn()
|
|
3249
|
+
const dispose = mount(
|
|
3250
|
+
h(TransitionGroup, {
|
|
3251
|
+
tag: 'div',
|
|
3252
|
+
name: 'fade',
|
|
3253
|
+
items,
|
|
3254
|
+
keyFn: (item: { id: number }) => item.id,
|
|
3255
|
+
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3256
|
+
onAfterLeave,
|
|
3257
|
+
}),
|
|
3258
|
+
el,
|
|
3259
|
+
)
|
|
3260
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3261
|
+
items.set([])
|
|
3262
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3263
|
+
dispose()
|
|
3264
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
3265
|
+
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
3266
|
+
})
|
|
3267
|
+
})
|
|
3268
|
+
|
|
3180
3269
|
// ─── Error paths (no ErrorBoundary) ──────────────────────────────────────────
|
|
3181
3270
|
|
|
3182
3271
|
describe('mount — error paths', () => {
|
package/src/tests/props.test.ts
CHANGED
|
@@ -269,6 +269,123 @@ describe('applyProp — innerHTML', () => {
|
|
|
269
269
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
270
270
|
warnSpy.mockRestore()
|
|
271
271
|
})
|
|
272
|
+
|
|
273
|
+
test('reactive innerHTML accessor — function value is called, not stringified', async () => {
|
|
274
|
+
// Regression: the JSX compiler emits `innerHTML={getIcon(props.x ? "a" : "b")}`
|
|
275
|
+
// as a `() => …` accessor. Without function-value handling here, the
|
|
276
|
+
// closure was set as literal text — `() => getIcon(...)` rendered
|
|
277
|
+
// verbatim instead of the SVG.
|
|
278
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
279
|
+
const el = document.createElement('div')
|
|
280
|
+
const which = signal<'a' | 'b'>('a')
|
|
281
|
+
const cleanup = applyProp(el, 'innerHTML', () => `<span data-x="${which()}">x</span>`)
|
|
282
|
+
expect(el.querySelector('[data-x="a"]')).not.toBeNull()
|
|
283
|
+
expect(el.innerHTML).not.toContain('=>')
|
|
284
|
+
which.set('b')
|
|
285
|
+
expect(el.querySelector('[data-x="b"]')).not.toBeNull()
|
|
286
|
+
cleanup?.()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('reactive dangerouslySetInnerHTML accessor — function value is called, not stringified', async () => {
|
|
290
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
291
|
+
const el = document.createElement('div')
|
|
292
|
+
const html = signal('<em>one</em>')
|
|
293
|
+
const cleanup = applyProp(el, 'dangerouslySetInnerHTML', () => ({ __html: html() }))
|
|
294
|
+
expect(el.innerHTML).toBe('<em>one</em>')
|
|
295
|
+
html.set('<em>two</em>')
|
|
296
|
+
expect(el.innerHTML).toBe('<em>two</em>')
|
|
297
|
+
cleanup?.()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('dev warning fires if a function reaches applyStaticProp directly (defensive guard)', () => {
|
|
301
|
+
// applyStaticProp is internal — reachable only if a future special-case
|
|
302
|
+
// branch in applyProp bypasses the reactive-wrap dance. The dev guard
|
|
303
|
+
// catches that regression at first render.
|
|
304
|
+
const el = document.createElement('div')
|
|
305
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
306
|
+
// Indirect: trigger by routing a function through `applyProp` for a
|
|
307
|
+
// key that DOESN'T have a special case — exercises the reactive path,
|
|
308
|
+
// which calls the accessor + passes the result. The accessor itself
|
|
309
|
+
// returning a function would surface the warning.
|
|
310
|
+
applyProp(el, 'innerHTML', () => () => '<em>nested</em>')
|
|
311
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
312
|
+
expect.stringContaining('applyStaticProp received a function for "innerHTML"'),
|
|
313
|
+
)
|
|
314
|
+
warnSpy.mockRestore()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Comprehensive sweep: every string-typed sink must handle reactive
|
|
319
|
+
// (function) values. The original bug was specific to innerHTML, but the
|
|
320
|
+
// structural fix should cover ALL sinks the same way. These tests assert
|
|
321
|
+
// that.
|
|
322
|
+
describe('applyProp — reactive function values across all sink kinds', () => {
|
|
323
|
+
test('reactive href accessor on <a>', async () => {
|
|
324
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
325
|
+
const el = document.createElement('a')
|
|
326
|
+
const path = signal('/one')
|
|
327
|
+
const cleanup = applyProp(el, 'href', () => path())
|
|
328
|
+
expect(el.getAttribute('href')).toBe('/one')
|
|
329
|
+
path.set('/two')
|
|
330
|
+
expect(el.getAttribute('href')).toBe('/two')
|
|
331
|
+
cleanup?.()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('reactive src accessor on <img>', async () => {
|
|
335
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
336
|
+
const el = document.createElement('img')
|
|
337
|
+
const url = signal('/a.png')
|
|
338
|
+
const cleanup = applyProp(el, 'src', () => url())
|
|
339
|
+
// <img> exposes `src` as a normalized absolute URL — assert via getAttribute
|
|
340
|
+
expect(el.getAttribute('src')).toBe('/a.png')
|
|
341
|
+
url.set('/b.png')
|
|
342
|
+
expect(el.getAttribute('src')).toBe('/b.png')
|
|
343
|
+
cleanup?.()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('reactive value accessor on <input>', async () => {
|
|
347
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
348
|
+
const el = document.createElement('input')
|
|
349
|
+
const val = signal('alpha')
|
|
350
|
+
const cleanup = applyProp(el, 'value', () => val())
|
|
351
|
+
expect((el as HTMLInputElement).value).toBe('alpha')
|
|
352
|
+
val.set('beta')
|
|
353
|
+
expect((el as HTMLInputElement).value).toBe('beta')
|
|
354
|
+
cleanup?.()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('reactive title accessor (data attribute pattern)', async () => {
|
|
358
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
359
|
+
const el = document.createElement('div')
|
|
360
|
+
const tip = signal('hello')
|
|
361
|
+
const cleanup = applyProp(el, 'title', () => tip())
|
|
362
|
+
expect(el.getAttribute('title')).toBe('hello')
|
|
363
|
+
tip.set('world')
|
|
364
|
+
expect(el.getAttribute('title')).toBe('world')
|
|
365
|
+
cleanup?.()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('reactive class accessor (string form)', async () => {
|
|
369
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
370
|
+
const el = document.createElement('div')
|
|
371
|
+
const cls = signal('one')
|
|
372
|
+
const cleanup = applyProp(el, 'class', () => cls())
|
|
373
|
+
expect(el.className).toBe('one')
|
|
374
|
+
cls.set('two')
|
|
375
|
+
expect(el.className).toBe('two')
|
|
376
|
+
cleanup?.()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('reactive style accessor (object form)', async () => {
|
|
380
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
381
|
+
const el = document.createElement('div')
|
|
382
|
+
const color = signal('red')
|
|
383
|
+
const cleanup = applyProp(el, 'style', () => ({ color: color() }))
|
|
384
|
+
expect(el.style.color).toBe('red')
|
|
385
|
+
color.set('blue')
|
|
386
|
+
expect(el.style.color).toBe('blue')
|
|
387
|
+
cleanup?.()
|
|
388
|
+
})
|
|
272
389
|
})
|
|
273
390
|
|
|
274
391
|
// ─── applyProp — URL safety ──────────────────────────────────────────────────
|
|
@@ -78,4 +78,49 @@ describe('Transition — safety-timer leak (regression)', () => {
|
|
|
78
78
|
await vi.advanceTimersByTimeAsync(6000)
|
|
79
79
|
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
80
80
|
})
|
|
81
|
+
|
|
82
|
+
// Regression: component unmount during an in-flight transition used to
|
|
83
|
+
// leave the 5s safety timer running. onAfterEnter / onAfterLeave would
|
|
84
|
+
// fire on a detached element up to 5 seconds after unmount.
|
|
85
|
+
test('onAfterEnter does NOT fire after component unmount during enter transition', async () => {
|
|
86
|
+
const el = container()
|
|
87
|
+
const show = signal(false)
|
|
88
|
+
const onAfterEnter = vi.fn()
|
|
89
|
+
const dispose = mount(
|
|
90
|
+
h(
|
|
91
|
+
Transition,
|
|
92
|
+
{ name: 'fade', show: () => show(), onAfterEnter },
|
|
93
|
+
h('div', { class: 'unmount-enter' }, 'x'),
|
|
94
|
+
),
|
|
95
|
+
el,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
show.set(true)
|
|
99
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
100
|
+
// Mid-transition — unmount without firing transitionend.
|
|
101
|
+
dispose()
|
|
102
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
103
|
+
expect(onAfterEnter).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('onAfterLeave does NOT fire after component unmount during leave transition', async () => {
|
|
107
|
+
const el = container()
|
|
108
|
+
const show = signal(true)
|
|
109
|
+
const onAfterLeave = vi.fn()
|
|
110
|
+
const dispose = mount(
|
|
111
|
+
h(
|
|
112
|
+
Transition,
|
|
113
|
+
{ name: 'fade', show: () => show(), onAfterLeave },
|
|
114
|
+
h('div', { class: 'unmount-leave' }, 'x'),
|
|
115
|
+
),
|
|
116
|
+
el,
|
|
117
|
+
)
|
|
118
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
119
|
+
|
|
120
|
+
show.set(false)
|
|
121
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
122
|
+
dispose()
|
|
123
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
124
|
+
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
81
126
|
})
|
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,13 +80,20 @@ 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)
|
|
@@ -105,9 +112,21 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
105
112
|
clearTimeout(safetyTimer)
|
|
106
113
|
safetyTimer = null
|
|
107
114
|
}
|
|
115
|
+
pendingEnterCancel = null
|
|
108
116
|
el.classList.remove(cls.ea, cls.et)
|
|
109
117
|
props.onAfterEnter?.(el)
|
|
110
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
|
+
}
|
|
111
130
|
el.addEventListener('transitionend', done, { once: true })
|
|
112
131
|
el.addEventListener('animationend', done, { once: true })
|
|
113
132
|
// Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
|
|
@@ -116,6 +135,8 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
const applyLeave = (el: HTMLElement) => {
|
|
138
|
+
pendingEnterCancel?.()
|
|
139
|
+
pendingEnterCancel = null
|
|
119
140
|
props.onBeforeLeave?.(el)
|
|
120
141
|
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
121
142
|
el.classList.add(cls.lf, cls.la)
|
|
@@ -181,6 +202,10 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
181
202
|
})
|
|
182
203
|
|
|
183
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
|
|
184
209
|
pendingLeaveCancel?.()
|
|
185
210
|
pendingLeaveCancel = null
|
|
186
211
|
})
|