Modern Web Weekly #10

Modern Web Weekly #10
Staying up to date with the modern web
👋
Hello there! I'm Danny and this is Modern Web Weekly, your weekly update on what the modern web is capable of, Web Components, and Progressive Web Apps (PWA). I test modern web features and write about them in plain English to make sure you stay up to date.

An outbox with Background Sync?

In Modern Web Weekly #8, I wrote about the Background Sync API that enables your web app to schedule tasks while offline and run them when the network is back.

Quite some subscribers asked me if it was possible to create some sort of "outbox" functionality, similar to the outbox of an email app: messages are stored when offline, and when the network is back the app loops through the saved messages and sends them. One issue that came up repeatedly was that some subscribers struggled to implement this with the online and offline events.

Actually, those events are not needed because, as I mentioned in the previous article, the beauty of the Background Sync API is that it handles all this for you.

Let's look at how we can implement an "outbox" with Background Sync.

We will build upon the demo that I added to What PWA Can Do Today which sends a push notification when the app is online but defers these when the app is offline until the network is back:

You may think that we could just execute multiple registration.sync.register calls but then we would need to change the sync-demo tag to something unique, otherwise any call will simply overwrite the previous one.

To solve this, you can in theory change the tag name to something unique, for example, add a timestamp. That way, you can register a separate sync event for each notification but since the event execution time is capped, the browser won't schedule it indefinitely so it's better to save the notifications to IndexedDB while offline and loop through them and send them when the app is back online.

But to only save the notifications to IndexedDB when the app is offline would force us to check if the app is offline and, again, the Background Sync API already handles that for us.

The solution for this is to always save any notification to IndexedDB and then immediately register a sync event. The event handler in the Service Worker will retrieve all saved notifications from IndexedDB (which, when the app is online will be only one, the one you just saved) and send them when the app is online. When the app is offline the notification won't be sent but stay inside IndexedDB.

Any other notification that is sent while offline will simply be saved and when the network is back the sync event fires and the app loops through all notifications in IndexedDB, sends them, and then removes them. This way, you can be sure no notification will be lost and all will be sent.

// inside the service worker an event handler is registered
// for the 'sync' event that sends the notification
self.addEventListener('sync', e => {
  e.waitUntil(
    self.registration.showNotification(title, options)
    .catch(err => console.log(err))
  )
});

// in the browser:   
const syncButton = document.querySelector('#sync-button');
const registration = await navigator.serviceWorker.getRegistration();

if('sync' in registration) {
  syncButton.addEventListener('click', async () => {
    await registration.sync.register('sync-demo');
  });
}

This does mean there is some overhead of saving notifications to IndexedDB but since it's optimized for fast storage this is a perfectly fine trade-off to make sure important tasks (like sending messages and network calls) are always executed, even if the network is not available.

Let's update the code to implement this:

// inside the service worker an event handler is registered
// for the 'sync' event that sends the notification
self.addEventListener('sync', e => {
  e.waitUntil(
    getNotifications()
    .then((notifications) => {
      const requests = notifications.map(({message}) => {
        return self.registration.showNotification(title, options);
      });

      return Promise.all(requests)
      .then(() => openStore())
      .then(idbStore => idbStore.clear());
    })
  )
});

// in the browser:   
const syncButton = document.querySelector('#sync-button');
const registration = await navigator.serviceWorker.getRegistration();
let notificationNum = 0;

if('sync' in registration) {
  syncButton.addEventListener('click', async () => {
    const notification = {
      timestamp: Date.now(),
      title: 'Background Sync Demo',
      message: `This is notification #${++notificationNum}`,
    };

    const idbStore = await getStore();
    await idbStore.add(notification);
    await registration.sync.register(`sync-demo`);
  });
}

In the event handler for the sync event we now get all notifications from IndexedDB, loop through them, and show each one. Then we clear all notifications from IndexedDB.

The click handler for the button now saves each notification to IndexedDB and then registers a sync (which will fire and send the notification immediately when online).

I omitted the code for IndexedDB for brevity but I hope you get the idea so you can implement it yourself for your own use cases.

Check the demo!

:nth-child() wizardry

You have probably used the :nth-child pseudo-class selector before. I have, but usually just for the "basic" stuff.

You know:

  • select the third child: :nth-child(3)
  • select all even children :nth-child(2n)
  • select all odd children :nth-child(2n+1)

The way the (2n) and (2n+1) syntax works is that n starts at 0 and is then incremented (1, 2, 3, etc.) so 2n then becomes 2 x 0, 2 x 1, 2 x 2, 2 x 3, etc. so we only get even numbers (0, 2, 4, 6, etc.).

Similarly, (2n + 1) renders odd numbers (2 x 0) + 1, (2 x 1) + 1, (2 x 2) +1, etc. so we get 1, 3, 5, etc.

(2n) and (2n+1) are referred to as An and An+B syntax.

I recently learned you can also omit A (2 in the examples) in the An+B syntax so you can start selecting from a certain number. For example, :nth-child(n+4) selects every child starting from the 4th one up (0+4, 1+4, 2+4, etc.).

You can even use a negative number to select children up to a certain number. For example, :nth-child(-n+3) selects every child up to the third one (0+3, -1+3, -2+3, -3+3. Selection will stop at 0 since -4+3 is -1 and selecting negative children doesn't make sense and won't work.

It becomes really interesting when you combine selectors to select a certain range. For example, :nth-child(n+3):nth-child(-n+7) selects all children from the third one up to the seventh one.

Another really cool selector is the :nth-child(An+B [of S]?) syntax.

For example, :nth-child(3 of .row) selects the third element that has class="row" and :nth-child(2n+1 of .row) selects all odd elements that have class="row".

In the :nth-child(An+B [of S]?) syntax, S accepts multiple selectors so the selector :nth-child(3 of .row, .disabled) selects the third element that has either class="row" or class="disabled".

You can combine selectors again to select a range:

:nth-child(n+2 of .row, .disabled):nth-child(-n+5 of .row, .disabled) selects all elements from the second one up to the fifth one that have either class="row" or class="disabled".

TIL: contenteditable with inputmode

The contenteditable attribute is used to make the content of an HTML element editable. This means that you can click a <div> for example and then type text into it or edit any text that is already in there. This is a technique often used by WYSIWYG editors.

Today I learned that you can also add inputmode to an element that has contenteditable. inputmode is primarily used on <input> elements to hint to the browser what type of data the user may enter so the browser can display an appropriate virtual keyboard on mobile devices. For example, inputmode="numeric" will display a numeric virtual keyboard on mobile devices.

By combining this with contenteditable, you can make mobile browsers display the same virtual keyboard for any HTML element that has this attribute.

Here's a codepen that demonstrates this. Check it on your phone to see the appropriate virtual keyboard display:

You may need to view this in the browser version:

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