Skip to content

Commit c3ffeba

Browse files
authored
[bugfix] Restore video playback after view-transition navigation on comfy.org (#15010)
## Summary A recent Chrome update broke every `<video>`/`<audio>` on comfy.org when the page is reached via Astro `<ClientRouter />` soft navigation (e.g. /learning → a tutorial watch page): the router parses the incoming page with DOMParser into an inert document where the media stack is never initialised, so swapped-in media reports `MEDIA_ERR_SRC_NOT_SUPPORTED` ("no supported sources") despite a valid `src`. Hard refresh works; any client-side navigation does not. ## Changes - **What**: Add `reifyMediaElements()` (mirrors unreleased upstream fix withastro/astro#17603) that replaces swapped-in media elements with fresh `document.createElement()` copies, run on `astro:after-swap` in `BaseLayout.astro` — synchronously before Vue island hydration, so `VideoPlayer.vue` hydrates onto the live elements. Also copies the `muted` property across (`setAttribute('muted')` only sets `defaultMuted`), without which muted autoplay loops stay blocked; upstream's fix has this gap. Unit tests cover attribute/child/muted copying and element position. - **Removal path**: Delete once Astro ships a release containing withastro/astro#17603 (not in 7.2.0; no 6.x backport). ## Review Focus - Timing: `astro:after-swap` fires synchronously after the swap, before island hydration microtasks run, so Vue never binds to the stale elements. Verified in Chromium: watch-page autoplay + custom controls, and home-page scroll-triggered loops all behave identically to a hard load after soft navigation.
1 parent 36aec4a commit c3ffeba

3 files changed

Lines changed: 89 additions & 0 deletions

File tree

apps/website/src/layouts/BaseLayout.astro

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const structuredData = noindex
198198
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
199199
import { ScrollTrigger } from '../scripts/gsapSetup'
200200
import { initPostHog, capturePageview } from '../scripts/posthog'
201+
import { reifyMediaElements } from '../scripts/reifyMediaElements'
201202

202203
initSmoothScroll()
203204

@@ -213,6 +214,11 @@ const structuredData = noindex
213214
document.addEventListener('astro:before-preparation', () => {
214215
cancelScroll()
215216
})
217+
218+
// Synchronous, so it runs before island hydration touches the DOM.
219+
document.addEventListener('astro:after-swap', () => {
220+
reifyMediaElements(document.body)
221+
})
216222
</script>
217223
</body>
218224
</html>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// @vitest-environment happy-dom
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { reifyMediaElements } from './reifyMediaElements'
5+
6+
const swapInFromInertDocument = (html: string) => {
7+
const inertDoc = new DOMParser().parseFromString(html, 'text/html')
8+
document.body.replaceChildren(...inertDoc.body.children)
9+
return document.body
10+
}
11+
12+
describe('reifyMediaElements', () => {
13+
it('replaces swapped-in media elements with live copies keeping attributes and children', () => {
14+
const body = swapInFromInertDocument(`
15+
<div>
16+
<video src="https://cdn.example/clip.mp4" poster="p.jpg" autoplay muted playsinline>
17+
<track src="captions.vtt" kind="captions" srclang="en" />
18+
</video>
19+
<audio preload="metadata">
20+
<source src="a.ogg" type="audio/ogg" />
21+
</audio>
22+
</div>
23+
`)
24+
const parsedVideo = body.querySelector('video')!
25+
const parsedAudio = body.querySelector('audio')!
26+
parsedVideo.muted = true
27+
28+
reifyMediaElements(body)
29+
30+
const video = body.querySelector('video')!
31+
expect(video).not.toBe(parsedVideo)
32+
expect(video.getAttribute('src')).toBe('https://cdn.example/clip.mp4')
33+
expect(video.getAttribute('poster')).toBe('p.jpg')
34+
expect(video.hasAttribute('autoplay')).toBe(true)
35+
expect(video.hasAttribute('muted')).toBe(true)
36+
expect(video.hasAttribute('playsinline')).toBe(true)
37+
expect(video.muted).toBe(true)
38+
expect(video.querySelector('track')?.getAttribute('src')).toBe(
39+
'captions.vtt'
40+
)
41+
42+
const audio = body.querySelector('audio')!
43+
expect(audio).not.toBe(parsedAudio)
44+
expect(audio.getAttribute('preload')).toBe('metadata')
45+
expect(audio.querySelector('source')?.getAttribute('src')).toBe('a.ogg')
46+
})
47+
48+
it('keeps each media element in its original position', () => {
49+
const body = swapInFromInertDocument(
50+
'<p>before</p><video src="v.mp4"></video><p>after</p>'
51+
)
52+
53+
reifyMediaElements(body)
54+
55+
expect(
56+
[...body.children].map((el) => el.tagName.toLowerCase())
57+
).toStrictEqual(['p', 'video', 'p'])
58+
})
59+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Replace media elements with fresh copies created in the live document.
3+
*
4+
* Astro's ClientRouter parses incoming pages with DOMParser, whose inert
5+
* document never initialises the browser's media stack, so after a soft
6+
* navigation every swapped-in <video>/<audio> reports "no supported
7+
* sources" and cannot play. Mirrors the upstream fix
8+
* (https://github.com/withastro/astro/issues/17601), which is not yet in
9+
* a released Astro version — remove this once we're on a release that
10+
* includes it.
11+
*/
12+
export function reifyMediaElements(root: ParentNode) {
13+
for (const media of root.querySelectorAll<HTMLMediaElement>('video, audio')) {
14+
const fresh = document.createElement(media.localName) as HTMLMediaElement
15+
for (const attr of media.attributes) {
16+
fresh.setAttribute(attr.name, attr.value)
17+
}
18+
// Copying the muted attribute only sets defaultMuted on an existing
19+
// element, and unmuted autoplay is blocked without user engagement.
20+
fresh.muted = media.muted
21+
fresh.innerHTML = media.innerHTML
22+
media.replaceWith(fresh)
23+
}
24+
}

0 commit comments

Comments
 (0)