bigskysoftware / htmx

</> htmx - high power tools for HTML
https://htmx.org
Other
38.14k stars 1.3k forks source link

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

Open croxton opened 2 years ago

croxton commented 2 years ago

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>?

1cg commented 2 years ago

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...

croxton commented 2 years ago

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 commented 2 years ago

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();
        }
    }
}
Jackky90 commented 2 years ago

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.

binaryfire commented 11 months ago

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