perf(ui): prefetch route chunks once the app is idle (#5027)
This commit is contained in:
+6
-3
@@ -3,9 +3,12 @@ import { createApp, App as VueApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.ts", { eager: true })).forEach((i) =>
|
||||
i.install?.(app),
|
||||
);
|
||||
// Specs live next to the modules they cover, and an eager glob would drag vitest into the entry chunk.
|
||||
Object.values(
|
||||
import.meta.glob<{ install: (app: VueApp) => void }>(["./modules/*.ts", "!./modules/*.spec.ts"], {
|
||||
eager: true,
|
||||
}),
|
||||
).forEach((i) => i.install?.(app));
|
||||
app.mount("#app");
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createRouter, createMemoryHistory } from "vue-router";
|
||||
import { prefetchRoutes } from "./router";
|
||||
|
||||
const page = () => ({ template: "<div/>" });
|
||||
|
||||
describe("prefetchRoutes", () => {
|
||||
test("imports every lazily loaded page", async () => {
|
||||
const home = vi.fn().mockResolvedValue(page());
|
||||
const settings = vi.fn().mockResolvedValue(page());
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: home },
|
||||
{ path: "/settings", component: settings },
|
||||
],
|
||||
});
|
||||
|
||||
await prefetchRoutes(router);
|
||||
|
||||
expect(home).toHaveBeenCalledOnce();
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("leaves eagerly bundled pages alone and survives a failed chunk", async () => {
|
||||
const broken = vi.fn().mockRejectedValue(new Error("Failed to fetch dynamically imported module"));
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: page() },
|
||||
{ path: "/broken", component: broken },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(prefetchRoutes(router)).resolves.toBeDefined();
|
||||
expect(broken).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type App } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { createRouter, createWebHistory, type Router } from "vue-router";
|
||||
import { routes } from "vue-router/auto-routes";
|
||||
import { setupLayouts } from "virtual:generated-layouts";
|
||||
|
||||
@@ -13,14 +13,57 @@ export const router = createRouter({
|
||||
const isStaleChunkError = (error: unknown) =>
|
||||
error instanceof Error && /dynamically imported module|Importing a module script failed/i.test(error.message);
|
||||
|
||||
// Every page chunk together is ~50 KB brotli, a quarter of the entry chunk, but importing one
|
||||
// cold still blocks the navigation long enough to read as lag. Warming them once the app is idle
|
||||
// makes every later navigation resolve straight from the module cache, on mobile too.
|
||||
export const prefetchRoutes = (router: Router) => {
|
||||
const loaders = router
|
||||
.getRoutes()
|
||||
.map((route) => route.components?.default)
|
||||
.filter((component): component is () => Promise<unknown> => typeof component === "function");
|
||||
|
||||
return Promise.all(loaders.map((load) => load().catch(() => {})));
|
||||
};
|
||||
|
||||
const whenIdle = (callback: () => void) =>
|
||||
"requestIdleCallback" in window ? requestIdleCallback(callback, { timeout: 2_000 }) : setTimeout(callback, 500);
|
||||
|
||||
export const install = (app: App) => {
|
||||
window.addEventListener("vite:preloadError", () => window.location.reload());
|
||||
let prefetching = false;
|
||||
let navigating = false;
|
||||
|
||||
router.beforeEach(() => {
|
||||
navigating = true;
|
||||
});
|
||||
router.afterEach(() => {
|
||||
navigating = false;
|
||||
});
|
||||
|
||||
window.addEventListener("vite:preloadError", (event) => {
|
||||
// A background prefetch that fails offline must not yank the page out from under someone
|
||||
// reading logs. Cancelling swallows the error, which the prefetch ignores anyway; a real
|
||||
// navigation still reloads.
|
||||
if (prefetching && !navigating) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
router.onError((error, to) => {
|
||||
navigating = false;
|
||||
if (isStaleChunkError(error)) {
|
||||
window.location.href = router.resolve(to).href;
|
||||
}
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
|
||||
// Save-Data means the browser is asking us not to spend bytes speculatively.
|
||||
if (!(navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData) {
|
||||
whenIdle(() => {
|
||||
prefetching = true;
|
||||
prefetchRoutes(router).finally(() => (prefetching = false));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user