Reflecting on hover-video-player v1.2

by Ryan Geyer —

Back in January when I released hover-video-player v1.1, it felt like it was finally a “finished” project; the component was meant to be extremely simple, and it seemed like there were no more obvious gaps in basic functionality that still needed to be added.

The next week, I got a new issue asking for a feature which was not currently supported. Classic.

For those unfamiliar, hover-video-player is a web component which can be used to add videos to your page which will play on hover, like a YouTube thumbnail preview. I use it on the homepage of this very site. It’s based on the significantly more popular react-hover-video-player library which I also maintain, but as I have fallen more and more out of love with React, I confess that hover-video-player has become my favorite child.

The issue I received was a single sentence, a fairly innocent question: “Is there an easy way to keep one video playing at all times, even when none is hovering?”

This one put me in a bad mood. This project isn’t finished at all. I did not and still don’t have any plans to build specific behavior into the component which involves syncing with other components’ states, but my existing implementation didn’t expose APIs which would allow you control component state externally in order to implement that behavior yourself.

I didn’t feel like I had any good ideas on how to approach the problem, nor did I feel much motivation to figure it out, so I just let the issue sit unaddressed. I spent the next 4 months feeling weirdly guilty about ignoring it, and then suddenly I recently had a burst of motivation to just power through and get this thing done.

So, here’s what I did!

The changes

These new APIs unlock everything that is needed in order to build the experience that the original issue that prompted all of this was asking about. I made a proof-of-concept in codepen which works pretty well!

Unfortunately I got a little overly excited and what started as a clean v1.2.0 release quickly turned into 4 rapid follow-up versions with lots of bug fixes and API refinements, finally bringing us to v1.2.4. This is the last one, I swear.

Having to make that many follow-up releases for a project is not the best, I’ll openly admit that. However, there is something extremely powerful about publishing something before it’s done. Like clockwork, as soon as it goes up, I’ll think of another issue that I didn’t account for and then I’m back in the code mines fixing it. It’s not a good loop, but you can’t argue it doesn’t get results. In the future, I’m going to try using a beta channel for releases until I’m 100% confident that everything is set in stone; maybe that will produce a similar effect without also putting people using the library in harm’s way.

What I learned

This library has helped me discover a ton of patterns I love for writing web components. When I first wrote hover-video-player, I stumbled into a new pattern which would later become popularized under the term “HTML Web Components”; components which enhance child content instead of being concerned with rendering the content themselves. It felt like an incredibly refreshing way of designing web components, and it even influenced how I design React components (I still need to write a blog going into more depth on that).

My favorite takeaway from this work was that JavaScript’s built-in event interfaces are really great to work with. I had to think very hard and go through a couple iterations figuring out how I could expose a reasonable API for disabling a <hover-video-player> element’s default behavior when a hover/blur event is fired, and achieving that by making the events cancelable with preventDefault() just feels so elegant and like it really goes with the grain of how native HTML elements work. Super refreshing.

For any who are curious how cancelable events work, this is it:

const evt = new Event("myevent", {
  cancelable: true,
});
// wasNotCanceled will be `false` if `evt.preventDefault()` was called in any handlers for this event.
// I kinda wish the values were flipped so it would return `true` if the event was canceled since this makes
// variable naming a little more awkward, but oh well.
const wasNotCanceled = eventTarget.dispatchEvent(evt);

I had no idea it was that easy!

However, I was also reminded that CustomEvents in TypeScript come with some extremely frustrating downsides; there is no good way to register typings for the events which a custom element can emit, and even if you know for certain that a given event will have a CustomEvent object instead of a plain Event object, TypeScript is going to fight you every step of the way until you’ve either added a bunch of extremely obnoxious unnecessary runtime checks or have just given up and cast everything as any.

For example, TypeScript will complain about this code block:

// ts error: Argument of type '(evt: CustomEvent<"paused" | "loading" | "playing>) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject'.
hoverVideoPlayer.addEventListener("playbackstatechange", (evt: CustomEvent<"paused" | "loading" | "playing">) => {});

This is what you have to do to satisfy it while still having evt.detail typed as expected:

hoverVideoPlayer.addEventListener("playbackstatechange", (evt) => {
  if (!(evt instanceof CustomEvent)) {
    return;
  }

  // evt.detail is now typed as any. Not very typesafe. The only way to narrow the type from
  // here is like this:
  const newPlaybackState: unknown = evt.detail;
  if(newPlaybackState !== "paused" && newPlaybackState !== "loading" && newPlaybackState !== "playing") {
    return;
  }

  // newPlaybackState can now be treated as a union of the 3 valid playback states
});

As web components continue to gain steam, this feels like an issue that TypeScript needs to address sooner than later. I’d be very interested to see if there are any proposals out there on how to make this developer experience better.