Will 2024 be the year of PWAs?

Will 2024 be the year of PWAs?
Photo by Claudio Schwarz / Unsplash

Modern Web Weekly #19

I never dared to dream when this day would come but it seems that sideloading and alternative browser engines are finally coming to iOS. Apple will open up iOS to alternative app stores and browsers on iOS will now also be allowed to bring their own engines instead of the Webkit engine that Apple now demands.

For now, these changes will only be rolled out in the EU since Apple needs to comply with the Digital Markets Act (DMA) by March 7 this year.

Right now it's still unclear how sideloading will work, but from Apple's support pages, it seems that developers will have to pay a hefty fee if they want to distribute their apps in another app store. In fact, this fee is so heavy that it remains to be seen if any developer would want this, let alone if they could afford it.

Apple calls this the "Core Technology Fee" and it's charged "to reflect the value Apple provides marketplace developers with ongoing investments in developer tools, technologies, and program services". It means that developers will need to pay €0.50 for each first annual install of their app when it's installed from an app store that is not the Apple App Store. For Instagram, this would come down to $11,277,174 per month.

It remains to be seen if Apple can get away with this and how this all will turn out. We just don't know, but at least the lid is off. At least there is now some movement that brings new hope that 2024 may finally be the year of PWAs.

Custom States now fully supported in Safari Tech Preview 187

The latest version of Safari Tech Preview now ships with full support for Custom States for web components. Safari and all other browsers already support the ElementInternals interface that enables web components to participate in form submission and validation.

This interface also enables developers to associate custom states with Custom Elements and style them based on these states.

The states property of ElementInternals returns a CustomStateSet that stores a list of possible states for a Custom Element to be in, and allows states to be added and removed from the set.

Each state in the set is represented by a string that currently has the same form as a CSS Custom Property, namely --mystate, but a new syntax has been proposed that drops this requirement so you can just use mystate. Safari Tech Preview and Firefox already support this new syntax

These states can then be accessed from CSS with the custom state pseudo-class in the same way that built-in states can be accessed.

For example, a checkbox that is checked can be accessed from CSS using the built-in :checked pseudo-class:

input[type=”checkbox”]:checked {
 outline: solid green;
}

Another example is a disabled button that can be accessed from CSS using the :disabled pseudo-class:

button:disabled {
 cursor: not-allowed;
}

In the same way, an element containing the custom state --mystate can be accessed from CSS like this:

/* old syntax */
my-element:--mystate {
 color: red;
}

/* new syntax */
my-element:state(mystate) {
 color: red;
}

A use case for Custom States

Custom states unlock a powerful feature.

They enable Web Components to be styled based on internal states without having to add attributes or classes to the component to reflect these states, so they stay fully internal.

For example, let’s say you have a <video-player> component that shows a play button to play a video.

When the play button is clicked and the video starts playing, you want this play button to be hidden and a pause button to be shown.

Then, when the pause button is clicked, it will be hidden and the play button will be shown again.

A simple way to do this is to introduce a playing property and reflect it to a playing attribute and use the :host pseudo-class to show and hide the buttons:

class VideoPlayer extends HTMLElement {

  constructor() {
    super();

    const shadowRoot = this.attachShadow({mode: 'open'});

    shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          width: 300px;
          height: 300px;
          border: 2px solid red;
          display: flex;
          justify-content: center;
          align-items: center;
          background-color: transparent;
        }

        #pause {
          display: none;
        }

        :host([playing]) #play {
          display: none;
        }

        :host([playing]) #pause {
          display: block;
        }
      </style>

      <button id="play" type="button">Play</button>
      <button id="pause" type="button">Pause</button
    `;
  }

  connectedCallback() {
    const playButton = this.shadowRoot.querySelector('#play');
    const pauseButton = this.shadowRoot.querySelector('#pause');

    playButton.addEventListener('click', () => {
      this.playing = true;
    });

    pauseButton.addEventListener('click', () => {
      this.playing = false;
    });
  }

  get playing() {
    return this.hasAttribute('playing');
  }

  set playing(isPlaying) {
    if(isPlaying) {
      this.setAttribute('playing', '');
    }
    else {
      this.removeAttribute('playing');
    }
  }
}

By default, the play button will be shown. A setter has been defined for the playing property that either sets or removes the playing attribute and the CSS rules take care of showing and hiding the buttons using the :hostpseudo-class.

Below is a working Codepen (you may need to view this in the browser version):

While this works fine, there is a potential problem with this implementation.

Exposing internal properties as attributes like this may not always be desirable and breaks encapsulation.

In this case, exposing a playing property may not be a bad idea but it does give users the ability to manually set the component in a playing state by just adding the attribute, but it won’t actually start playing the video.

Exposing this property may even raise the expectation that the video can be played by just adding the playing attribute.

In fact, adding an attribute to put a Web Component in a certain state doesn’t really put it in that state because it doesn’t set the corresponding property: just adding the playing attribute does not set the playing property to true.

While in this case, it may not cause real harm, there will always be cases where exposing internal properties is not a good idea.

This is a perfect use case for custom states: no properties will be exposed but the component can still be styled using CSS based on these states.

Adding and removing custom states

As mentioned, all custom states are stored in a CustomStateSet object that is stored in the states property of the ElementInternals interface.

It has the methods add and delete to add and remove states and the hasmethod to check if the element has a certain state:

// attach the internals
this.internals = this.attachInternals();

// add states old syntax
this.internals.states.add('--foo');

// new syntax
this.internals.states.add('foo');

// check for existence of states old syntax
this.internals.states.has('--foo'); 

// new syntax
this.internals.states.has('foo'); 

To make the previous example work with custom states, the getter and setter for the `playing` property are changed to work with the states:

get playing() {
  return this.internals.states.has('playing');
}

set playing(isPlaying) {
  if(isPlaying) {
    this.internals.states.add('playing');
  }
  else {
    this.internals.states.delete('playing');
  }
}

And the CSS is changed to this:

/* old syntax */
host(:--playing) #play {
  display: none;
}

:host(:--playing) #pause {
  display: block;
}

/* new syntax */
host(:state(playing)) #play {
  display: none;
}

:host(:state(playing)) #pause {
  display: block;
}

Here's a Codepen that also makes internals private (so it becomes this.#internals) so users can't change the state from the outside and works with both the old and new syntax so it works in all browsers:

You may need to view this in the browser version

Currently, Custom States are supported in Chrome, Edge, Safari Tech Preview with the CustomStateSet feature flag enabled and in Firefox 122 with dom.element.customstateset.enabled set to true.

🔗
Got an interesting link for Modern Web Weekly?
Send me a DM on Twitter to let me know!