htmx icon indicating copy to clipboard operation
htmx copied to clipboard

Safari (web and iOS) doesn't re-create / copy the ShadowRoot when a web component is swapped

Open croxton opened this issue 3 years ago • 5 comments

I discovered this issue when trying to swap a native <video> element into a <div>.

<video>has a default Shadow DOM for the player chrome and controls.

<button hx-get="video.html" hx-target="#content">
    Load a video
</button>

<div id="content"></div>

video.html :

<video id="video" controls="controls" preload="none" width="600">
   <source id='mp4' src="http://media.w3.org/2010/05/sintel/trailer.mp4" type='video/mp4' />
   <source id='webm' src="http://media.w3.org/2010/05/sintel/trailer.webm" type='video/webm' />
   <source id='ogv' src="http://media.w3.org/2010/05/sintel/trailer.ogv" type='video/ogg' />
</video>

<button id="play">Play</button>
<button id="pause">Pause</button>

<script>
    var movie = document.querySelector("#video");
    var playBtn = document.querySelector("#play");
    var pauseBtn = document.querySelector("#pause");
    playBtn.addEventListener('click', function(e) {
        movie.play();
    });
    pauseBtn.addEventListener('click', function(e) {
        movie.pause();
    });
</script>

Chrome and Firefox render a ShadowRoot for the video element correctly, and display the native video controls. In Safari the Shadow DOM is empty and no controls are displayed - but the movie can still be controlled programatically (the movie plays but is blank when paused).

I'm not really sure what the expected behaviour is when swapping a web component, and if it differs for 'native' and custom web components. Are Chrome and Firefox cloning the Shadow DOM tree when htmx swaps an element, and Safari isn't? Or are they recreating it automatically after the swap because <video> has a default shadow dom? Is there a way to re-initialise the default shadow DOMs of elements like <video>?

croxton avatar Jan 13 '22 16:01 croxton

Hmmm, this is way above my pay grade. Are you willing to look into the issue?

It sounds like a Safari bug to me, but if we can work around it I'm willing to look at the code...

1cg avatar Jan 16 '22 17:01 1cg

Good to know, cheers. Yes it does feel like a Safari bug (it really is the new IE, sigh).

I'm going to try some experiments to see if I can clone or extend the browser's default web components, so I can re-initialise them when swapped. I'll update this issue if I get anywhere. Thanks again!

croxton avatar Jan 17 '22 15:01 croxton

For anyone running into this in the future, the only reliable solution I found was to insert / remove <video> from a placeholder in the dom using javascript, rather than have htmx swap and cache the raw markup.

This is how I'm using it, with lazy loading as a bonus. The idea here is that the update() method of this component is called when htmx swaps content:

/**
 * Background video
 *
 * Mount and play a background video (autoplay, muted)
 */

/**
 * Example markup
 *
  <div class="w-full h-full"
     data-background-video
     data-src-mp4="/my/video.mp4"
     data-src-webm="/my/video.webm"
     data-class="w-full h-full object-cover">
  </div>
 */

import BaseComponent from '../modules/baseComponent';

export default class BackgroundVideo extends BaseComponent {

    videoObserver = null;
    selector = '[data-background-video]';

    constructor() {
        super();
        this.mount();
    }

    mount() {

        // lazy load videos as they enter the viewport
        const videos = document.querySelectorAll(this.selector);
        let self = this;

        if ("IntersectionObserver" in window) {
            this.videoObserver = new IntersectionObserver(function(entries, observer) {
                entries.forEach(function(videoEntry) {
                    if (videoEntry.isIntersecting) {
                        let videoContainer = videoEntry.target;
                        let video = document.createElement("video");
                        if (video.canPlayType("video/webm")) {
                            let src = videoContainer.dataset.srcWebm;
                            video.setAttribute("src", src);
                        } else {
                            let src = videoContainer.dataset.srcMp4;
                            video.setAttribute("src", src);
                        }

                        // autoplay video - note that it must be muted
                        video.autoplay = true;
                        video.loop = true;
                        video.playsinline = true;
                        video.muted = true;

                        // add classes
                        let cssClass = videoContainer.dataset.class;
                        if (cssClass) {
                            video.setAttribute("class", cssClass);
                        }

                        // insert into dom
                        videoContainer.appendChild(video);

                        // when the video is able to play, add a class to the container for unveil animation
                        video.oncanplay = function() {
                            videoContainer.classList.add('can-play');
                        }

                        // kill observer
                        self.videoObserver.unobserve(videoContainer);
                    }
                });

            });

            videos.forEach(function(video) {
                self.videoObserver.observe(video);
            });
        }
    }

    destroy() {
        let videos = document.querySelectorAll(this.selector);

        for (let [i, videoContainer] of [...videos].entries()) {
            videoContainer.innerHTML = null;
            this.videoObserver.unobserve(videoContainer);
        }
        this.videoObserver = null;
        videos = null;
    }

    unmount() {
        if (this.mounted) {

            // cleanup
            this.destroy();

            // remove component reference
            this.ref = null;
        }
    }

    update(e) {
        // Update strategy:
        // - remove all videos from the dom
        // - unobserve video containers
        // - re-initialise
        if (document.contains(document.querySelector(this.selector))) {

            // cleanup
            this.destroy();

            // mount again
            this.mount();
        }
    }
}

croxton avatar Feb 24 '22 17:02 croxton

Spent tons of hours, testing different lazyload libs getting same weird results in my posts infinite timeline: iOS (latest) Safari with devTools showed the <img> was dynamically set with valid src attr, but still not visible... And then I decided to dive into htmx issue tracker, doesn't expect to find some useful notes about that and.... I see, that there is no core workarounds yet, but now I now what to look at, thanks. If any updates, will be happy a lot, also if I find a working solution for my case, will paste it here.

Jackky90 avatar May 03 '22 16:05 Jackky90

Hi all. Looks like Unpoly have found a fix for this. Could the same approach be applied for HTMX?

Issue: https://github.com/unpoly/unpoly/issues/432#event-10978912949 Fix: https://github.com/unpoly/unpoly/commit/8044fd2a5a6c87c2936fd113dfc423d558a13630

binaryfire avatar Nov 16 '23 10:11 binaryfire