perf(ui): prefetch route chunks once the app is idle (#5027)

This commit is contained in:
Amir Raminfar
2026-09-05 21:41:19 -07:00
committed by GitHub
parent 5b14626ace
commit b570b14b02
3 changed files with 92 additions and 5 deletions
+6 -3
View File
@@ -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) {
+41
View File
@@ -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();
});
});
+45 -2
View File
@@ -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));
});
}
};