Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Use modulepreload to speculatively prefetch #193

Open
shairez opened this issue Nov 15, 2024 Discussed in #182 · 1 comment
Open

RFC: Use modulepreload to speculatively prefetch #193

shairez opened this issue Nov 15, 2024 Discussed in #182 · 1 comment
Assignees
Labels
[STAGE-2] incomplete implementation Remove this label when implementation is complete [STAGE-2] not fully covered by tests yet Remove this label when tests are verified to cover the implementation [STAGE-2] unresolved discussions left Remove this label when all critical discussions are resolved on the issue [STAGE-3] docs changes not added yet Remove this label when the necessary documentation for the feature / change is added [STAGE-3] missing 2 reviews for RFC PRs Remove this label when at least 2 core team members reviewed and approved the RFC implementation

Comments

@shairez
Copy link
Contributor

shairez commented Nov 15, 2024

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)

@shairez shairez self-assigned this Nov 15, 2024
@github-project-automation github-project-automation bot moved this to In Progress (STAGE 2) in Qwik Evolution Nov 15, 2024
@github-actions github-actions bot added [STAGE-2] incomplete implementation Remove this label when implementation is complete [STAGE-2] not fully covered by tests yet Remove this label when tests are verified to cover the implementation [STAGE-2] unresolved discussions left Remove this label when all critical discussions are resolved on the issue [STAGE-3] docs changes not added yet Remove this label when the necessary documentation for the feature / change is added [STAGE-3] missing 2 reviews for RFC PRs Remove this label when at least 2 core team members reviewed and approved the RFC implementation labels Nov 15, 2024
@GrandSchtroumpf
Copy link

I've done more research about it. The reason modulepreload affects FCP/LCP is because the links are read very early in the processus. To prevent that we should wait for the idle callback.
A simple solution is to wrap all the <link rel="modulepreload" .../> into a <template> tag and clone the content on idle callback.
Here is a simple component that demonstrate how to achieve that :

import { component$, sync$, useVisibleTask$ } from "@builder.io/qwik"
import { manifest } from '@qwik-client-manifest';

export const Preload = component$(() => {
  const bundles = Object.keys(manifest?.bundles ?? {}).map((bundle) => `/build/${bundle}`);
  
  useVisibleTask$(sync$(() => {
    const template = document.getElementById('preload-bundles') as HTMLTemplateElement;
    const links = template.content.cloneNode(true);
    document.head.appendChild(links);
  }), { strategy: 'document-idle' });

  return (
    <template id="preload-bundles">
      {bundles.map((bundle, i) => (
        <link key={i} rel="modulepreload" href={bundle} fetchPriority="low" />
      ))}
    </template>
  );
})

In this example I'm preloading all the bundles, but if we can access a more precise list it would work the same. You can combine it with the code above to have a fully working solution with the SW.

Here is a screenshot of performance panel at load time with the component above:
image
We can see that DCL/L/FP/FCP/LCP are unaffected by early preload.

Note: The idle callback isn't well supported by Safari (https://bugs.webkit.org/show_bug.cgi?id=164193) so we would need a polyfill. But this is required for useVisibleTask$'s 'document-idle' strategy anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[STAGE-2] incomplete implementation Remove this label when implementation is complete [STAGE-2] not fully covered by tests yet Remove this label when tests are verified to cover the implementation [STAGE-2] unresolved discussions left Remove this label when all critical discussions are resolved on the issue [STAGE-3] docs changes not added yet Remove this label when the necessary documentation for the feature / change is added [STAGE-3] missing 2 reviews for RFC PRs Remove this label when at least 2 core team members reviewed and approved the RFC implementation
Projects
Status: In progress
Status: In Progress (STAGE 2)
Development

No branches or pull requests

2 participants