From fbc1bc219268b6854215e4610ff5917f1b6ab5e6 Mon Sep 17 00:00:00 2001 From: Yevanchen Date: Sat, 22 Aug 2026 09:53:57 +0800 Subject: [PATCH] feat(web): preload routes on navigation intent --- apps/web/src/app/route-guards.tsx | 40 +- apps/web/src/app/route-registry.tsx | 81 +++- apps/web/src/app/router.tsx | 57 ++- apps/web/src/main.tsx | 4 +- apps/web/src/shared/i18n/catalogs.ts | 25 ++ apps/web/src/shared/i18n/init.ts | 10 +- apps/web/src/shared/i18n/locale-switcher.tsx | 2 +- apps/web/src/shared/i18n/provider.tsx | 33 +- apps/web/tests/i18n-catalog-loading.test.ts | 18 + e2e/benchmarks/web-page-loads.ts | 379 +++++++++++++++++++ e2e/package.json | 1 + 11 files changed, 606 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/shared/i18n/catalogs.ts create mode 100644 apps/web/tests/i18n-catalog-loading.test.ts create mode 100644 e2e/benchmarks/web-page-loads.ts diff --git a/apps/web/src/app/route-guards.tsx b/apps/web/src/app/route-guards.tsx index 71c79b93..dae1562c 100644 --- a/apps/web/src/app/route-guards.tsx +++ b/apps/web/src/app/route-guards.tsx @@ -1,10 +1,11 @@ -import { lazy } from "react"; +import { use } from "react"; import type { ReactElement, ReactNode } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useTranslation } from "@/shared/i18n"; import { UploadRecoveryDialog } from "../features/files/upload-recovery/upload-recovery-dialog"; +import type * as AppShell from "./app-shell"; import { useAppSession } from "./session-provider"; // The authenticated app shell (sidebar navigation, account/help menus, org @@ -12,17 +13,32 @@ import { useAppSession } from "./session-provider"; // it lazily keeps the whole shell subtree out of the entry chunk, so the // public /login route — the cold-start page for first-time and // logged-out visitors, where the shell never mounts — no longer pays to -// download it. Both wrappers pull the same "./app-shell" module, so they share -// one chunk and a signed-in visitor fetches it in parallel with the first route -// chunk (both are dynamic imports resolved after the same auth check). -const Layout = lazy(async () => { - const appShell = await import("./app-shell"); - return { default: appShell.Layout }; -}); -const OrgLayout = lazy(async () => { - const appShell = await import("./app-shell"); - return { default: appShell.OrgLayout }; -}); +// download it. Both wrappers share one cached "./app-shell" import, so switching +// between the App and Org layouts never suspends again after either shell loads. +type AppShellModule = typeof AppShell; + +let loadedAppShell: AppShellModule | undefined; +let appShellPromise: Promise | undefined; + +function loadAppShell(): Promise { + appShellPromise ??= import("./app-shell").then((appShell) => { + loadedAppShell = appShell; + return appShell; + }); + return appShellPromise; +} + +function Layout({ children }: RouteChildrenProps): ReactElement { + const appShell = loadedAppShell ?? use(loadAppShell()); + const AppLayout = appShell.Layout; + return {children}; +} + +function OrgLayout({ children }: RouteChildrenProps): ReactElement { + const appShell = loadedAppShell ?? use(loadAppShell()); + const OrganizationLayout = appShell.OrgLayout; + return {children}; +} interface RouteChildrenProps { children: ReactNode; diff --git a/apps/web/src/app/route-registry.tsx b/apps/web/src/app/route-registry.tsx index 94061fef..f42a1a82 100644 --- a/apps/web/src/app/route-registry.tsx +++ b/apps/web/src/app/route-registry.tsx @@ -1,4 +1,4 @@ -import { lazy } from "react"; +import { createElement, use } from "react"; import type { ComponentType, ReactElement, ReactNode } from "react"; import { Navigate, useParams, useRoutes } from "react-router-dom"; import type { RouteObject } from "react-router-dom"; @@ -11,10 +11,20 @@ function lazyNamed( load: () => Promise>, exportName: TName, ) { - return lazy(async () => { - const routeModule = await load(); - return { default: routeModule[exportName] }; - }); + let loadedRoute: { default: ComponentType } | undefined; + let routePromise: Promise<{ default: ComponentType }> | undefined; + const preload = () => { + routePromise ??= load().then((routeModule) => { + loadedRoute = { default: routeModule[exportName] }; + return loadedRoute; + }); + return routePromise; + }; + function PreloadableRoute(): ReactElement { + const route = loadedRoute ?? use(preload()); + return createElement(route.default); + } + return Object.assign(PreloadableRoute, { preload }); } function protectedRoute(element: ReactElement): ReactElement { @@ -104,6 +114,67 @@ const OrgSettings = lazyNamed( "OrgSettingsPage", ); +type PreloadableRoute = { preload(): Promise }; + +// Intent prefetches include both nested layouts and redirect destinations so a +// subsequent navigation can commit without entering the top-level Suspense +// fallback. Dynamic parameter routes are resolved immediately below. +const staticRoutePreloads: Record = { + "/": [AppOverview], + "/agent": [AgentList], + "/app-settings": [AppSettingsLayout, AppSettingsGeneral], + "/app-settings/cost": [AppSettingsLayout, AppUsage], + "/app-settings/general": [AppSettingsLayout, AppSettingsGeneral], + "/app-settings/usage": [AppSettingsLayout, AppUsage], + "/apps": [AppsList], + "/cli-auth": [CliAuth], + "/cost": [AppSettingsLayout, AppUsage], + "/deployments": [AppOverview], + "/environment": [Environments], + "/environments": [Environments], + "/files": [Files], + "/integrations": [SkillsTabRoute], + "/integrations/mcp": [McpTabRoute], + "/integrations/mcp/oauth-complete": [McpOAuthComplete], + "/integrations/skills": [SkillsTabRoute], + "/login": [Login], + "/mcp": [McpTabRoute], + "/onboarding": [Onboarding], + "/org/settings": [OrgSettings], + "/profile": [SettingsLayout, SettingsProfile], + "/providers": [Providers], + "/settings": [SettingsLayout, SettingsProfile], + "/settings/access-tokens": [SettingsLayout, SettingsAccessTokens], + "/settings/app": [SettingsLayout, AppSettingsLayout, AppSettingsGeneral], + "/settings/cost": [SettingsLayout, AppSettingsLayout, AppUsage], + "/settings/environments": [SettingsLayout, Environments], + "/settings/profile": [SettingsLayout, SettingsProfile], + "/settings/usage": [SettingsLayout, AppSettingsLayout, AppUsage], + "/skill": [SkillsTabRoute], + "/skills": [SkillsTabRoute], + "/threads": [Threads], + "/usage": [AppSettingsLayout, AppUsage], + "/v0-deploy-preview": [V0DeployPreview], +}; + +function dynamicRoutePreloads(pathname: string): PreloadableRoute[] { + if (/^\/environments?\/[^/]+$/.test(pathname)) { + return [Environments]; + } + if (/^\/agent\/[^/]+$/.test(pathname)) { + return [AgentDetail]; + } + if (/^\/threads\/[^/]+$/.test(pathname)) { + return [Threads]; + } + return []; +} + +export async function preloadRoute(pathname: string): Promise { + const routes = staticRoutePreloads[pathname] ?? dynamicRoutePreloads(pathname); + await Promise.all(routes.map(async (route) => route.preload())); +} + const appRoutes = [ { element: ( diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index d514676d..32f9d8df 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,17 +1,70 @@ -import { Suspense } from "react"; +import { Suspense, useEffect, useLayoutEffect } from "react"; +import { useLocation } from "react-router-dom"; import { DocumentTitle } from "./document-title"; import { AppLoading } from "./route-guards"; -import { AppRoutes } from "./route-registry"; +import { AppRoutes, preloadRoute } from "./route-registry"; const appLoadingFallback = ; +const ROUTE_READY_MARK_PREFIX = "mosoo:route-ready:"; +const ROUTE_PREFETCHED_MARK_PREFIX = "mosoo:route-prefetched:"; + +function RouteIntentPrefetcher() { + useEffect(() => { + const prefetchedAnchors = new WeakMap(); + const prefetch = (event: Event) => { + if (!(event.target instanceof Element)) { + return; + } + const anchor = event.target.closest("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + const url = new URL(anchor.href, globalThis.location.href); + if ( + url.origin !== globalThis.location.origin || + prefetchedAnchors.get(anchor) === url.pathname + ) { + return; + } + prefetchedAnchors.set(anchor, url.pathname); + void preloadRoute(url.pathname).then( + () => performance.mark(`${ROUTE_PREFETCHED_MARK_PREFIX}${url.pathname}`), + () => { + prefetchedAnchors.delete(anchor); + }, + ); + }; + + document.addEventListener("focusin", prefetch); + document.addEventListener("pointerover", prefetch); + return () => { + document.removeEventListener("focusin", prefetch); + document.removeEventListener("pointerover", prefetch); + }; + }, []); + + return null; +} + +function RouteReadyMarker() { + const location = useLocation(); + + useLayoutEffect(() => { + performance.mark(`${ROUTE_READY_MARK_PREFIX}${location.pathname}${location.search}`); + }, [location.pathname, location.search]); + + return null; +} export function App() { return ( <> + + ); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9350f7fd..65875c00 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,8 +14,8 @@ if (!container) { } // Initialise i18n before rendering so the first paint already uses the -// correct locale. The init promise resolves synchronously when resources -// are bundled (no async chunk load), so this does not delay the render. +// correct locale. English stays in the entry chunk; other locales load only +// their selected catalog instead of making every page download all catalogs. void initI18n().then(() => { createRoot(container).render( diff --git a/apps/web/src/shared/i18n/catalogs.ts b/apps/web/src/shared/i18n/catalogs.ts new file mode 100644 index 00000000..2ce1d577 --- /dev/null +++ b/apps/web/src/shared/i18n/catalogs.ts @@ -0,0 +1,25 @@ +import type { SupportedLocale } from "./locales"; +import en from "./translations/en.json"; + +export type TranslationValue = string | Record; +export type TranslationTree = Record; + +const loadedCatalogs = new Map([["en", en]]); +const catalogLoaders: Record, () => Promise> = { + ja: async () => (await import("./translations/ja.json")).default, + "zh-CN": async () => (await import("./translations/zh-CN.json")).default, + "zh-TW": async () => (await import("./translations/zh-TW.json")).default, +}; + +export function getTranslationCatalog(locale: SupportedLocale): TranslationTree { + return loadedCatalogs.get(locale) ?? en; +} + +export async function loadTranslationCatalog(locale: SupportedLocale): Promise { + if (loadedCatalogs.has(locale) || locale === "en") { + return; + } + + const load = catalogLoaders[locale]; + loadedCatalogs.set(locale, await load()); +} diff --git a/apps/web/src/shared/i18n/init.ts b/apps/web/src/shared/i18n/init.ts index 0173d2ce..acb067dc 100644 --- a/apps/web/src/shared/i18n/init.ts +++ b/apps/web/src/shared/i18n/init.ts @@ -1,14 +1,16 @@ +import { loadTranslationCatalog } from "./catalogs"; import { DEFAULT_LOCALE, resolveLocale } from "./locales"; import type { SupportedLocale } from "./locales"; /** - * Prepare the bundled translation resources before the first render. + * Prepare only the selected translation resource before the first render. */ -export function initI18n(): Promise { +export async function initI18n(): Promise { + const locale = getCurrentLocale(); + await loadTranslationCatalog(locale); if (typeof document !== "undefined") { - document.documentElement.lang = getCurrentLocale(); + document.documentElement.lang = locale; } - return Promise.resolve(); } /** diff --git a/apps/web/src/shared/i18n/locale-switcher.tsx b/apps/web/src/shared/i18n/locale-switcher.tsx index 9f10b076..bcb4da91 100644 --- a/apps/web/src/shared/i18n/locale-switcher.tsx +++ b/apps/web/src/shared/i18n/locale-switcher.tsx @@ -67,7 +67,7 @@ export function LocaleSwitcher({ key={locale} className={cn("cursor-pointer rounded-md", locale === current && "font-medium")} onClick={() => { - i18n.changeLanguage(locale); + void i18n.changeLanguage(locale); }} > {LOCALE_DISPLAY_NAMES[locale]} diff --git a/apps/web/src/shared/i18n/provider.tsx b/apps/web/src/shared/i18n/provider.tsx index e6ad67a7..5aaf2993 100644 --- a/apps/web/src/shared/i18n/provider.tsx +++ b/apps/web/src/shared/i18n/provider.tsx @@ -1,28 +1,17 @@ -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; +import { getTranslationCatalog, loadTranslationCatalog } from "./catalogs"; +import type { TranslationTree, TranslationValue } from "./catalogs"; import { DEFAULT_LOCALE, resolveLocale } from "./locales"; import type { SupportedLocale } from "./locales"; -import en from "./translations/en.json"; -import ja from "./translations/ja.json"; -import zhCN from "./translations/zh-CN.json"; -import zhTW from "./translations/zh-TW.json"; - -type TranslationValue = string | Record; -type TranslationTree = Record; type I18nContextValue = { language: SupportedLocale; - changeLanguage: (language: SupportedLocale) => void; + changeLanguage: (language: SupportedLocale) => Promise; t: (key: string, variables?: Record) => string; }; -const resources: Record = { - en, - ja, - "zh-CN": zhCN, - "zh-TW": zhTW, -}; const I18nContext = createContext(null); function lookup(tree: TranslationTree, key: string): string { @@ -40,17 +29,25 @@ function detectInitialLocale(): SupportedLocale { export function I18nProvider({ children }: { children: ReactNode }) { const [language, setLanguage] = useState(detectInitialLocale); + const requestedLanguageRef = useRef(language); const value = useMemo( () => ({ language, - changeLanguage(next) { + async changeLanguage(next) { + requestedLanguageRef.current = next; + await loadTranslationCatalog(next); + if (requestedLanguageRef.current !== next) { + return; + } setLanguage(next); localStorage.setItem("mosoo-locale", next); document.documentElement.lang = next; }, t(key, variables) { - let text = lookup(resources[language], key); - if (text === key && language !== DEFAULT_LOCALE) text = lookup(resources.en, key); + let text = lookup(getTranslationCatalog(language), key); + if (text === key && language !== DEFAULT_LOCALE) { + text = lookup(getTranslationCatalog(DEFAULT_LOCALE), key); + } return Object.entries(variables ?? {}).reduce( (result, [name, replacement]) => result.replaceAll(`{{${name}}}`, replacement), text, diff --git a/apps/web/tests/i18n-catalog-loading.test.ts b/apps/web/tests/i18n-catalog-loading.test.ts new file mode 100644 index 00000000..ad3199ee --- /dev/null +++ b/apps/web/tests/i18n-catalog-loading.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; + +import { getTranslationCatalog, loadTranslationCatalog } from "../src/shared/i18n/catalogs"; + +describe("translation catalog loading", () => { + test("keeps English available without an asynchronous catalog load", () => { + expect(getTranslationCatalog("en")["common"]).toBeDefined(); + }); + + test("loads a selected non-default catalog on demand", async () => { + const englishCommon = getTranslationCatalog("en")["common"]; + + await loadTranslationCatalog("ja"); + + expect(getTranslationCatalog("ja")["common"]).toBeDefined(); + expect(getTranslationCatalog("ja")["common"]).not.toEqual(englishCommon); + }); +}); diff --git a/e2e/benchmarks/web-page-loads.ts b/e2e/benchmarks/web-page-loads.ts new file mode 100644 index 00000000..3a5d8a6b --- /dev/null +++ b/e2e/benchmarks/web-page-loads.ts @@ -0,0 +1,379 @@ +import { chromium } from "@playwright/test"; +import type { BrowserContext, Page } from "@playwright/test"; + +const baseUrl = process.env["MOSOO_WEB_BENCHMARK_URL"] ?? "http://127.0.0.1:4173"; +const executablePath = process.env["PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"]; +const sampleCount = readPositiveInteger("MOSOO_WEB_BENCHMARK_SAMPLES", 5); +const thresholdMs = readPositiveNumber("MOSOO_WEB_BENCHMARK_THRESHOLD_MS", 50); +const timeoutMs = readPositiveNumber("MOSOO_WEB_BENCHMARK_TIMEOUT_MS", 5_000); +const ROUTE_READY_MARK_PREFIX = "mosoo:route-ready:"; +const ROUTE_PREFETCHED_MARK_PREFIX = "mosoo:route-prefetched:"; +const AUTHENTICATED_BOOTSTRAP_PATH = "/cli-auth"; +const AUTHENTICATED_ALTERNATE_BOOTSTRAP_PATH = "/apps"; +const PUBLIC_BOOTSTRAP_PATH = "/integrations/mcp/oauth-complete"; +const PUBLIC_ALTERNATE_BOOTSTRAP_PATH = "/v0-deploy-preview"; + +const ACCOUNT_ID = "01J0000000000000000000000D"; +const ORGANIZATION_ID = "01J0000000000000000000000E"; +const APP_ID = "01J0000000000000000000000F"; +const ENVIRONMENT_ID = "01J0000000000000000000000G"; +const AGENT_ID = "01J0000000000000000000000H"; +const THREAD_ID = "01J0000000000000000000000J"; + +type SessionFixture = "authenticated" | "guest" | "onboarding"; + +interface PageCase { + expectedPath?: string; + path: string; + session: SessionFixture; +} + +// Each sample boots a stable page in a fresh context, completes the same +// pointer-intent prefetch users trigger before clicking, then times navigation +// through the destination route commit. Prefetch duration is reported +// separately. Keep one concrete URL for every RouteObject in +// route-registry.tsx; aliases and redirects remain user-visible entry URLs. +const pageCases: PageCase[] = [ + { path: "/login", session: "guest" }, + { path: "/onboarding", session: "onboarding" }, + { path: "/integrations/mcp/oauth-complete", session: "guest" }, + { path: "/cli-auth", session: "authenticated" }, + { path: "/", session: "authenticated" }, + { path: "/apps", session: "authenticated" }, + { path: "/org/settings", session: "authenticated" }, + { path: "/files", session: "authenticated" }, + { path: "/environment", session: "authenticated" }, + { path: `/environment/${ENVIRONMENT_ID}`, session: "authenticated" }, + { expectedPath: "/environment", path: "/environments", session: "authenticated" }, + { + expectedPath: `/environment/${ENVIRONMENT_ID}`, + path: `/environments/${ENVIRONMENT_ID}`, + session: "authenticated", + }, + { expectedPath: "/integrations/skills", path: "/integrations", session: "authenticated" }, + { expectedPath: "/integrations/skills", path: "/skill", session: "authenticated" }, + { expectedPath: "/integrations/skills", path: "/skills", session: "authenticated" }, + { expectedPath: "/integrations/mcp", path: "/mcp", session: "authenticated" }, + { path: "/integrations/skills", session: "authenticated" }, + { path: "/integrations/mcp", session: "authenticated" }, + { expectedPath: "/", path: "/deployments", session: "authenticated" }, + { path: "/v0-deploy-preview", session: "guest" }, + { path: "/agent", session: "authenticated" }, + { path: `/agent/${AGENT_ID}`, session: "authenticated" }, + { path: "/threads", session: "authenticated" }, + { path: `/threads/${THREAD_ID}`, session: "authenticated" }, + { + expectedPath: "/app-settings/general", + path: "/app-settings", + session: "authenticated", + }, + { path: "/app-settings/general", session: "authenticated" }, + { path: "/app-settings/usage", session: "authenticated" }, + { + expectedPath: "/app-settings/usage", + path: "/app-settings/cost", + session: "authenticated", + }, + { expectedPath: "/settings/profile", path: "/settings", session: "authenticated" }, + { path: "/settings/profile", session: "authenticated" }, + { path: "/settings/access-tokens", session: "authenticated" }, + { + expectedPath: "/app-settings/general", + path: "/settings/app", + session: "authenticated", + }, + { + expectedPath: "/app-settings/usage", + path: "/settings/usage", + session: "authenticated", + }, + { + expectedPath: "/environment", + path: "/settings/environments", + session: "authenticated", + }, + { + expectedPath: "/app-settings/usage", + path: "/settings/cost", + session: "authenticated", + }, + { expectedPath: "/settings/profile", path: "/profile", session: "authenticated" }, + { expectedPath: "/app-settings/usage", path: "/usage", session: "authenticated" }, + { path: "/providers", session: "authenticated" }, + { expectedPath: "/app-settings/usage", path: "/cost", session: "authenticated" }, +]; + +interface PageResult { + maxLoadMs: number; + medianLoadMs: number; + medianPrefetchMs: number; + path: string; +} + +interface PageMeasurement { + loadMs: number; + prefetchMs: number; +} + +function readPositiveInteger(name: string, fallback: number): number { + const value = Number(process.env[name] ?? fallback); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + return value; +} + +function readPositiveNumber(name: string, fallback: number): number { + const value = Number(process.env[name] ?? fallback); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive number.`); + } + return value; +} + +function median(values: readonly number[]): number { + const sorted = values.toSorted((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + const current = sorted[middle]; + if (current === undefined) { + throw new Error("Cannot calculate a median without samples."); + } + if (sorted.length % 2 === 1) { + return current; + } + const previous = sorted[middle - 1]; + if (previous === undefined) { + throw new Error("Cannot calculate a median without samples."); + } + return (previous + current) / 2; +} + +function viewerFixture(session: SessionFixture): Record { + const account = + session === "guest" + ? null + : { + email: "benchmark@mosoo.ai", + id: ACCOUNT_ID, + imageUrl: null, + name: "Page-load benchmark", + systemAgentModel: null, + }; + const organization = + session === "authenticated" + ? { + avatarUrl: null, + createdAt: "2026-01-01T00:00:00.000Z", + id: ORGANIZATION_ID, + name: "Benchmark organization", + } + : null; + + return { + data: { + viewer: { + account, + activeOrganization: organization, + auth: { currentSecurityLevel: "session", methods: [] }, + organizations: organization === null ? [] : [organization], + }, + }, + }; +} + +function appListFixture(): Record { + return { + data: { + appList: [ + { + createdAt: "2026-01-01T00:00:00.000Z", + defaultEnvironmentId: ENVIRONMENT_ID, + id: APP_ID, + name: "Benchmark app", + ownerAccountId: ACCOUNT_ID, + }, + ], + }, + }; +} + +async function prepareContext(context: BrowserContext, session: SessionFixture): Promise { + await context.addInitScript( + ({ appListResponse, viewerResponse }) => { + localStorage.setItem("mosoo-locale", "en"); + + const originalFetch = window.fetch.bind(window); + const benchmarkFetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const inputUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(inputUrl, window.location.href); + if (!url.pathname.startsWith("/api/")) { + return originalFetch(input, init); + } + + let query = ""; + if (typeof init?.body === "string") { + try { + const body = JSON.parse(init.body) as { query?: unknown }; + query = typeof body.query === "string" ? body.query : ""; + } catch { + // Return the deterministic benchmark error below for malformed input. + } + } + + const payload = query.includes("query Viewer") + ? viewerResponse + : query.includes("query AppList") + ? appListResponse + : { errors: [{ message: "The page-load benchmark does not resolve route data." }] }; + return new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + status: url.pathname === "/api/graphql" ? 200 : 503, + }); + }; + window.fetch = benchmarkFetch as typeof window.fetch; + }, + { + appListResponse: appListFixture(), + viewerResponse: viewerFixture(session), + }, + ); +} + +function routeReadyMarkName(path: string): string { + return `${ROUTE_READY_MARK_PREFIX}${path}`; +} + +async function waitForRouteReady(page: Page, path: string, startedAt = 0): Promise { + const markName = routeReadyMarkName(path); + await page.waitForFunction( + ({ minimumStartTime, name }) => + performance + .getEntriesByName(name, "mark") + .some((entry) => entry.startTime >= minimumStartTime), + { minimumStartTime: startedAt, name: markName }, + { timeout: timeoutMs }, + ); + return page.evaluate((name) => { + const entries = performance.getEntriesByName(name, "mark"); + const latest = entries.at(-1); + if (latest === undefined) { + throw new Error(`The route-ready mark is missing: ${name}`); + } + return latest.startTime; + }, markName); +} + +async function prefetchRoute(page: Page, path: string): Promise { + const markName = `${ROUTE_PREFETCHED_MARK_PREFIX}${new URL(path, baseUrl).pathname}`; + const startedAt = await page.evaluate((href) => { + const anchor = document.createElement("a"); + anchor.href = href; + document.body.append(anchor); + const start = performance.now(); + anchor.dispatchEvent(new PointerEvent("pointerover", { bubbles: true })); + anchor.remove(); + return start; + }, path); + await page.waitForFunction( + ({ minimumStartTime, name }) => + performance + .getEntriesByName(name, "mark") + .some((entry) => entry.startTime >= minimumStartTime), + { minimumStartTime: startedAt, name: markName }, + { timeout: timeoutMs }, + ); + const finishedAt = await page.evaluate((name) => { + const latest = performance.getEntriesByName(name, "mark").at(-1); + if (latest === undefined) { + throw new Error(`The route-prefetched mark is missing: ${name}`); + } + return latest.startTime; + }, markName); + return finishedAt - startedAt; +} + +async function measurePage(pageCase: PageCase): Promise { + const context = await browser.newContext(); + await prepareContext(context, pageCase.session); + const page = await context.newPage(); + try { + const bootstrapPath = + pageCase.session === "authenticated" + ? pageCase.path === AUTHENTICATED_BOOTSTRAP_PATH + ? AUTHENTICATED_ALTERNATE_BOOTSTRAP_PATH + : AUTHENTICATED_BOOTSTRAP_PATH + : pageCase.path === PUBLIC_BOOTSTRAP_PATH + ? PUBLIC_ALTERNATE_BOOTSTRAP_PATH + : PUBLIC_BOOTSTRAP_PATH; + await page.goto(new URL(bootstrapPath, baseUrl).href, { waitUntil: "commit" }); + await waitForRouteReady(page, bootstrapPath); + + const prefetchMs = await prefetchRoute(page, pageCase.path); + const startedAt = await page.evaluate((path) => { + const start = performance.now(); + history.pushState(history.state, "", path); + window.dispatchEvent(new PopStateEvent("popstate", { state: history.state })); + return start; + }, pageCase.path); + const readyAt = await waitForRouteReady( + page, + pageCase.expectedPath ?? pageCase.path, + startedAt, + ); + return { + loadMs: Math.round((readyAt - startedAt) * 100) / 100, + prefetchMs: Math.round(prefetchMs * 100) / 100, + }; + } finally { + await context.close(); + } +} + +const browser = await chromium.launch({ + ...(executablePath === undefined ? {} : { executablePath }), + headless: true, +}); + +try { + const results: PageResult[] = []; + for (const pageCase of pageCases) { + const samples: PageMeasurement[] = []; + for (let sample = 0; sample < sampleCount; sample += 1) { + samples.push(await measurePage(pageCase)); + } + const samplesMs = samples.map((sample) => sample.loadMs); + const prefetchSamplesMs = samples.map((sample) => sample.prefetchMs); + results.push({ + maxLoadMs: Math.max(...samplesMs), + medianLoadMs: median(samplesMs), + medianPrefetchMs: median(prefetchSamplesMs), + path: pageCase.path, + }); + } + + console.table( + results.map((result) => ({ + "max load ms": result.maxLoadMs.toFixed(2), + "median load ms": result.medianLoadMs.toFixed(2), + "median prefetch ms": result.medianPrefetchMs.toFixed(2), + route: result.path, + })), + ); + + const failures = results.filter((result) => result.medianLoadMs >= thresholdMs); + console.log( + `Measured ${results.length} routes with ${sampleCount} fresh-context, intent-prefetched samples each; navigation-to-route-commit median limit ${thresholdMs.toFixed(2)} ms.`, + ); + if (failures.length > 0) { + console.error( + `Routes over the limit: ${failures.map((failure) => `${failure.path} (${failure.medianLoadMs.toFixed(2)} ms)`).join(", ")}`, + ); + process.exitCode = 1; + } +} finally { + await browser.close(); +} diff --git a/e2e/package.json b/e2e/package.json index dd2ceab4..760b638c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "benchmark:web-page-loads": "bun benchmarks/web-page-loads.ts", "tc": "vp exec tsc --noEmit" }, "dependencies": {