Skip to content

RFC: Use modulepreload to speculatively prefetch #193

@shairez

Description

@shairez

Discussed in #182

Originally posted by GrandSchtroumpf October 14, 2024

What is it about?

Use to prefetch modules

What's the motivation for this proposal?

Problems you are trying to solve:

We cannot use the Cache API to prefetch module in devmode which leads to poor performance

Goals you are trying to achieve:

Separate the speculative prefetch process from the caching process.

Any other context or information you want to share:

  • modulepreload has been discussed and discarded in the early day of qwik because Firefox didn't support it. This is not the case anymore.
  • There is an existing prefetchStrategy -> linkRel: 'modulepreload', but it only prefetch initial modules, those known at build time.
  • This topic has already been discussed in the late August 2024 and tests have been performed (https://github.com/thejackshelton/sw-playground)
  • One of the main concern was that modulepreload could increase FCP/LCP. It was decided to change Qwik doc prefetch strategy to accumulate real user data to see if this is the case.

If modulepreload affect negatively FCP/LCP it's still possible to use prefetchStrategy -> linkInsert: 'js-append' instead of 'html-append' to delay to initial prefetch.


Proposed Solution / Feature

What do you propose?

Separate Speculative Prefetch from Caching

Speculative Prefetch (should work in dev mode) :
Use <link rel="modulepreload" /> to prefetch the modules:

  1. At build time, the server will gather all required modules and include modulepreload in the head (similar to linkInsert: 'html-append' prefetchStrategy)
  2. on DOMContentLoaded to start listening on qprefetch event
  3. on qprefetch event, append new <link rel="modulepreload" /> tags with the bundle href. If modulepreload is not support just do a regular fetch for the Service Worker to hook it.

Caching (should work only in prod mode) :

  1. on DOMContentLoaded register service worker, send all existing modulepreload hrefs to the service worker to cache.
  2. on fetch cache the response if it's a qwik module

Code examples

This is a working code snippet. To use it

  1. in entry.ssr set prefetchStrategy :
prefetchStrategy: {
  implementation: {
    linkInsert: 'html-append',
    linkRel: 'modulepreload',
  }
},
  1. In root.tsx remove <PrefetchServiceWorker /> & <PrefetchGraph /> and add useModulePrelaod() (see code snippet)
  2. Create sw.js in the public folder (see code snippet)

useModulePreload.tsx

import { sync$, useOnDocument, useOnWindow } from "@builder.io/qwik";

export const useModulePreload = () => {
  // Initialize SW & cache
  useOnWindow(
    "DOMContentLoaded",
    sync$(async () => {
      const isDev = document.documentElement.getAttribute('q:render') === "ssr-dev";
      if ("serviceWorker" in navigator && !isDev) {
        await navigator.serviceWorker.register("/sw.js");
        await navigator.serviceWorker.ready;
        const modules = document.querySelectorAll<HTMLLinkElement>('link[rel="modulepreload"]');
        const controller = navigator.serviceWorker.controller;
        const hrefs = Array.from(modules).map((link) => link.href);
        controller?.postMessage({ type: "init", value: hrefs });
      }
    })
  );

  // Listen on prefetch event
  useOnDocument('qprefetch', sync$((event: CustomEvent<{ bundles: string[] }>) => {
    const { bundles } = (event as CustomEvent).detail;
    if (!Array.isArray(bundles)) return;
    const base = document.documentElement.getAttribute("q:base") ?? "/";
    const isDev = document.documentElement.getAttribute('q:render') === "ssr-dev";
    const getHref = (bundle: string) => {
      if (isDev) return bundle;
      return `${base}${bundle}`.replace(/\/\./g, "");
    }
    const supportsModulePreload = document.querySelector('link')?.relList.supports('modulepreload');
    for (const bundle of bundles) {
      if (supportsModulePreload) {
        const link = document.createElement("link");
        link.rel = 'modulepreload';
        link.fetchPriority = 'low';
        link.href = getHref(bundle);
        document.head.appendChild(link);
      } else {
        // triggers the sw if modulepreload is not supported
        fetch(getHref(bundle));
      }
    }
  }));
};

sw.js

const main = async () => {
  let cache;

  const fetchResponse = async (req) => {
    // Check cache
    const cachedResponse = await caches.match(req);
    if (cachedResponse) return cachedResponse;

    // Cache and return reponse
    return fetch(req).then((res) => {
      if (req.url.includes("q-") && req.url.endsWith('.js')) {
        cache.put(req, res.clone());
      }
      return res;
    })
  }

  self.addEventListener("activate", async (event) => {
    event.waitUntil(caches.open("QwikModulePreload"));
  });
  self.addEventListener("message", async (message) => {
    if (message.data.type === "init") {
      cache ||= await caches.open("QwikModulePreload");
      new Set(message.data.value).forEach(url => {
        // force-cache to use disk cache if modulepreload was already executed
        return fetchResponse(new Request(url, { cache: 'force-cache' }));
      });
    }
  });
  self.addEventListener("fetch", async (event) => {
    cache ||= await caches.open("QwikModulePreload");
    event.respondWith(fetchResponse(event.request));
  });
};
main();
addEventListener("install", () => self.skipWaiting());
addEventListener("activate", () => self.clients.claim());

Links / References

MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/modulepreload
Experiment: https://github.com/thejackshelton/sw-playground
app using useModulePreload: https://qwik-playground.vercel.app/ (github: https://github.com/GrandSchtroumpf/qwik-hueeye)

Metadata

Metadata

Labels

Type

No type

Projects

Status

Done

Status

Released as Stable (STAGE 5)

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions