diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index d4ebf0f0..ecd09277 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -12,6 +12,7 @@ import { AuthContext } from "../../contexts/auth/AuthContext.ts"; import type { AuthResponse, SelfUser } from "../../../shared/user.ts"; import type { UserSettings } from "../../../shared/settings.ts"; import type { Session } from "../../utils/auth/storage.ts"; +import { takeEarly } from "../../utils/shared/earlyFetch.ts"; import { forgetStoredSession, isTokenExpired, @@ -113,7 +114,13 @@ export function useAuth(): UseAuthResult { const isFormData = init.body instanceof FormData; - const res = await fetch(input, { + // The page a visit lands on finds its first request already sent, as the + // app booted (utils/shared/earlyFetch.ts). + const early = typeof input === "string" && + (init.method ?? "GET") === "GET" + ? takeEarly(input, token) + : undefined; + const res = await (early ?? fetch(input, { ...init, headers: { ...(init.headers ?? {}), @@ -121,7 +128,7 @@ export function useAuth(): UseAuthResult { // The browser sets FormData's Content-Type, with its boundary. ...(isFormData ? {} : { "Content-Type": "application/json" }), }, - }); + })); if (res.status === 401) { logout(); diff --git a/src/i18n.ts b/src/i18n.ts index 78a5cc9c..90188a98 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -11,6 +11,8 @@ import { isLocale, type Locale } from "../shared/locale.ts"; /** * Returns the stored locale, else French for a French browser, else English. + * The built page asks for that locale's messages ahead of this module, by the + * same rule (`preloadCatalog` in vite.config.ts): the two must agree. */ function detectLocale(): Locale { const stored = localStorage.getItem("locale"); diff --git a/src/main.tsx b/src/main.tsx index 8292af8b..98744154 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ /** * Starts the frontend. It loads the stylesheets and the interface's language, - * then mounts `App` on index.html's `#root`. + * sends the first request of the page the visit lands on, then mounts `App` + * on index.html's `#root`. * * A deploy renames every chunk, so a tab opened before it fails to load the * lazy chunks it hasn't fetched yet. It then reloads to pick up the new build, @@ -17,6 +18,7 @@ import { I18nProvider } from "@lingui/react"; import { App } from "./App.tsx"; import { loadCatalog } from "./i18n.ts"; +import { startLandingRequest } from "./utils/shared/earlyFetch.ts"; import "./fonts.css"; import "./tokens.css"; @@ -42,6 +44,10 @@ globalThis.addEventListener("vite:preloadError", (event) => { location.reload(); }); +// The page this visit lands on is loaded with its route, and asks the API for +// its content once loaded: that request leaves now, with the page's code. +startLandingRequest(location.pathname); + // Before the first render, or it would show untranslated message ids. await loadCatalog(); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 04a8c602..47eb51f6 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -14,8 +14,7 @@ import { FeedTabBar } from "../components/feed/FeedTabBar.tsx"; import { type FeedTab, type FollowView } from "../../shared/paths.ts"; import { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; -import { API_URL } from "../config/api.ts"; - +import { feedApiUrl } from "../utils/shared/urls.ts"; import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts"; import type { Dump } from "../../shared/dump.ts"; import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts"; @@ -84,9 +83,7 @@ export function Index( const defaultTab = useViewerDefaultFeedTab(); const tab: FeedTab = feedTab ?? defaultTab; - const feedUrl = categorySlug - ? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}` - : `${API_URL}/api/dumps`; + const feedUrl = feedApiUrl(categorySlug); const fetchPage = useCallback( (page: number, signal?: AbortSignal) => authFetch(pageUrl(feedUrl, page), { signal }) diff --git a/src/test/hooks/auth/useAuth.test.tsx b/src/test/hooks/auth/useAuth.test.tsx index a7ae2057..c38ee7a5 100644 --- a/src/test/hooks/auth/useAuth.test.tsx +++ b/src/test/hooks/auth/useAuth.test.tsx @@ -7,6 +7,9 @@ import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts"; import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts"; import { calls, route } from "../../fetch.ts"; import { makeSession, makeToken, storeSession } from "../../fixtures.ts"; +import { startLandingRequest } from "../../../utils/shared/earlyFetch.ts"; +import { feedApiUrl } from "../../../utils/shared/urls.ts"; +import { pageUrl } from "../../../utils/feed/pagedList.ts"; function wrapperFor(initial: Session | null) { return function Wrapper({ children }: { children: ReactNode }) { @@ -103,6 +106,19 @@ describe("authFetch", () => { ); }); + test("a GET the app started as it booted is handed over, not sent again", async () => { + route("GET /api/dumps", () => ({ items: ["early"], hasMore: false })); + startLandingRequest("/"); + const feed = pageUrl(feedApiUrl(), 1); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) }); + const res = await result.current.authFetch(feed); + expect((await res.json()).data.items).toEqual(["early"]); + expect(calls("GET /api/dumps")).toHaveLength(1); + // Once: the next one is the page's own. + await result.current.authFetch(feed); + expect(calls("GET /api/dumps")).toHaveLength(2); + }); + test("no Authorization header when signed out", async () => { route("GET /api/open", () => ({})); const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) }); diff --git a/src/test/setup.ts b/src/test/setup.ts index 741345e5..d3139a5e 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -7,6 +7,7 @@ import { i18n } from "@lingui/core"; import { messages } from "../../locales/en.po"; import { FakeWebSocket } from "./ws.ts"; import { resetFetch } from "./fetch.ts"; +import { clearEarlyRequests } from "../utils/shared/earlyFetch.ts"; // Lazy chunks and the app's own debounces (a link preview waits 600ms) make // the default 1s too tight once every file runs in parallel. @@ -179,6 +180,7 @@ document.execCommand ??= () => false; beforeEach(() => { resetFetch(); + clearEarlyRequests(); FakeWebSocket.reset(); vi.stubGlobal("WebSocket", FakeWebSocket); FakeIntersectionObserver.instances = []; diff --git a/src/test/utils/shared/earlyFetch.test.ts b/src/test/utils/shared/earlyFetch.test.ts new file mode 100644 index 00000000..ecf0d5c0 --- /dev/null +++ b/src/test/utils/shared/earlyFetch.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + clearEarlyRequests, + landingRequest, + startLandingRequest, + takeEarly, +} from "../../../utils/shared/earlyFetch.ts"; +import { API_URL } from "../../../config/api.ts"; +import { feedApiUrl, userApiUrl } from "../../../utils/shared/urls.ts"; +import { pageUrl } from "../../../utils/feed/pagedList.ts"; +import { makeSession, storeSession } from "../../fixtures.ts"; + +describe("earlyFetch", () => { + const feed = pageUrl(feedApiUrl(), 1); + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(() => Promise.resolve(new Response("{}"))); + vi.stubGlobal("fetch", fetchMock); + localStorage.clear(); + clearEarlyRequests(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + test("landingRequest names the feed's first page and a dump, as their pages do", () => { + expect(landingRequest("/")).toBe(feed); + expect(landingRequest("/hot")).toBe(feed); + expect(landingRequest("/journal/")).toBe(feed); + expect(landingRequest("/@ann/dumps/a-song")).toBe( + userApiUrl({ username: "ann" }, "dumps", "a-song"), + ); + expect(landingRequest("/@ann/dumps/a-song")).toBe( + `${API_URL}/api/users/@ann/dumps/a-song`, + ); + // A category or an app page, a profile and an editor wait for their code. + expect(landingRequest("/music")).toBeNull(); + expect(landingRequest("/settings")).toBeNull(); + expect(landingRequest("/follow/playlists")).toBeNull(); + expect(landingRequest("/@ann")).toBeNull(); + expect(landingRequest("/@ann/dumps/a-song/edit")).toBeNull(); + expect(landingRequest("/@ann/playlists/mix")).toBeNull(); + }); + + test("a request started is taken once, by the URL it was sent to", async () => { + startLandingRequest("/"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe(feed); + expect(takeEarly(`${feed}&x=1`, undefined)).toBeUndefined(); + const res = await takeEarly(feed, undefined); + expect(await res!.text()).toBe("{}"); + expect(takeEarly(feed, undefined)).toBeUndefined(); + }); + + test("a page that starts none sends nothing", () => { + startLandingRequest("/settings"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("it goes out as the stored session, and only that session takes it", () => { + const session = makeSession(); + storeSession(session); + startLandingRequest("/"); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe( + `Bearer ${session.token}`, + ); + // Signed out since: the answer held the user's private dumps. + expect(takeEarly(feed, undefined)).toBeUndefined(); + startLandingRequest("/"); + expect(takeEarly(feed, session.token)).toBeDefined(); + }); + + test("an answer nobody took in time is dropped", () => { + vi.useFakeTimers(); + startLandingRequest("/"); + vi.advanceTimersByTime(11_000); + expect(takeEarly(feed, undefined)).toBeUndefined(); + }); + + test("a request that fails rejects in the hands of whoever takes it", async () => { + fetchMock.mockImplementation(() => Promise.reject(new TypeError("down"))); + startLandingRequest("/"); + await expect(takeEarly(feed, undefined)).rejects.toThrow("down"); + }); +}); diff --git a/src/utils/shared/earlyFetch.ts b/src/utils/shared/earlyFetch.ts new file mode 100644 index 00000000..f20da147 --- /dev/null +++ b/src/utils/shared/earlyFetch.ts @@ -0,0 +1,95 @@ +/** + * Starts the first request of the page a visit lands on before that page's + * code has loaded, so that the two travel together rather than one after the + * other. `main.tsx` starts it as the app boots ({@link startLandingRequest}), + * and `useAuth`'s `authFetch` hands its response to the page that asks for + * the same URL ({@link takeEarly}), in place of a request of its own. + * + * @module + */ + +import { FEED_TABS, parsePagePath } from "../../../shared/paths.ts"; +import { readStoredSession } from "../auth/storage.ts"; +import { pageUrl } from "../feed/pagedList.ts"; +import { feedApiUrl, userApiUrl } from "./urls.ts"; + +/** + * How long a response waits for its page. Past it, the page asks again: what + * was answered as the app booted isn't what a list holds a minute later. + */ +const MAX_AGE_MS = 10_000; + +/** The requests started and not yet taken, by URL. */ +const started = new Map< + string, + { response: Promise; token: string | undefined; at: number } +>(); + +/** + * Returns the URL the page at `pathname` loads first, as that page builds it, + * or null for a page that isn't started early: the feed of every dump, on any + * of its tabs, asks for its first page, and a dump's page for the dump. The + * other pages wait for their code, as before. + */ +export function landingRequest(pathname: string): string | null { + const [first, ...more] = pathname.split("/").filter(Boolean); + if ( + first === undefined || + (more.length === 0 && (FEED_TABS as readonly string[]).includes(first)) + ) return pageUrl(feedApiUrl(), 1); + + const page = parsePagePath(pathname); + if (page?.kind === "dump" && !page.edit) { + return userApiUrl({ username: page.username }, "dumps", page.slug); + } + return null; +} + +/** + * Sends the request {@link landingRequest} names for `pathname`, with the + * stored session's token as `authFetch` would, and keeps its response for + * {@link takeEarly}. It never throws: a request that fails does so in the + * hands of the page that takes it, as its own would. + */ +export function startLandingRequest(pathname: string): void { + const url = landingRequest(pathname); + if (!url) return; + let token: string | undefined; + try { + token = readStoredSession()?.token; + } catch { + // Without storage there is no session, and the request goes as a guest's. + } + const response = fetch(url, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "Content-Type": "application/json", + }, + }); + // Nobody may take it: the failure is then nobody's to handle. + response.catch(() => {}); + started.set(url, { response, token, at: Date.now() }); +} + +/** + * Returns the response of the request started for `url`, once and to the + * session it was sent as, or undefined when there is none, or none recent + * enough: the caller then sends its own. `authFetch` asks it for every GET. + */ +export function takeEarly( + url: string, + token: string | undefined, +): Promise | undefined { + const early = started.get(url); + if (!early) return undefined; + started.delete(url); + if (early.token !== token || Date.now() - early.at > MAX_AGE_MS) { + return undefined; + } + return early.response; +} + +/** Forgets the requests started, between the tests' cases. */ +export function clearEarlyRequests(): void { + started.clear(); +} diff --git a/src/utils/shared/urls.ts b/src/utils/shared/urls.ts index 820ff277..9c1b5e83 100644 --- a/src/utils/shared/urls.ts +++ b/src/utils/shared/urls.ts @@ -87,6 +87,17 @@ function derivedMediaUrl( }`; } +/** + * Returns the URL of the feed's dumps, `GET /api/dumps`, every dump's or + * those of the category `categorySlug`; `pageUrl` adds the page to it. The + * index page loads it, and `landingRequest` names it to start it early. + */ +export function feedApiUrl(categorySlug?: string): string { + return categorySlug + ? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}` + : `${API_URL}/api/dumps`; +} + /** * Returns what a user typed as a link, trimmed, with `https://` added when it * has no http(s) scheme. The result is not checked to be a valid URL. diff --git a/vite.config.ts b/vite.config.ts index 3266c3c1..2734ac99 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -104,6 +104,42 @@ function precompress(): Plugin { }; } +// The interface's messages are a chunk of their own per language, which +// main.tsx imports before rendering anything (src/i18n.ts). Left to that +// import, the chunk is only asked for once the entry and the framework have +// loaded and run. This puts a script in the page's head that asks for it at +// once, beside them, picking the language as `detectLocale` does there: the +// two must agree, or the wrong language is fetched for nothing. +function preloadCatalog(): Plugin { + return { + name: "preload-catalog", + apply: "build", + transformIndexHtml: { + order: "post", + handler(_html, ctx) { + const catalogs: Record = {}; + for (const chunk of Object.values(ctx.bundle ?? {})) { + if (chunk.type !== "chunk") continue; + const locale = linguiConfig.locales.find((l) => + chunk.moduleIds.some((id) => id.split("?")[0].endsWith(`/${l}.po`)) + ); + if (locale) catalogs[locale] = `/${chunk.fileName}`; + } + if (Object.keys(catalogs).length === 0) return []; + return [{ + tag: "script", + injectTo: "head-prepend", + children: `(function(){var c=${JSON.stringify(catalogs)},l;` + + `try{l=localStorage.getItem("locale")}catch(e){}` + + `if(!c[l])l=navigator.language.indexOf("fr")===0?"fr":"en";` + + `if(!c[l])return;var k=document.createElement("link");` + + `k.rel="modulepreload";k.href=c[l];document.head.appendChild(k)})();`, + }]; + }, + }, + }; +} + // Each dev instance (dev/instances.ts) runs a Vite server of its own; sharing // one dependency cache, two of them would rewrite it under each other. const INSTANCE = process.env.GERBEUR_INSTANCE; @@ -166,6 +202,7 @@ export default defineConfig({ }, }, emojibase(), + preloadCatalog(), precompress(), lingui(), react({