v4: the page a visit lands on asks for its content while its code loads — a first load went one step after the other: the entry script, then the interface's messages, then the page's chunk and its twenty-odd dependencies, and only then the request for the feed. The built page now carries a script in its head that asks for the messages' chunk at once, beside the entry, picking the language as detectLocale does (preloadCatalog in vite.config.ts; the two must agree). And main.tsx sends the landing page's first request as the app boots (utils/shared/earlyFetch.ts): the feed's first page on / and its tabs, the dump on a dump's page, as the stored session. authFetch hands that response to the page that asks for the same URL, once, to the session it was sent as and within ten seconds, in place of a request of its own; past that the page asks again. feedApiUrl (utils/shared/urls.ts) is the feed's URL for both. A category's feed, a profile, a playlist and the other pages wait for their code as before. On a local build with 150 ms of latency added, the feed's request leaves at 0.69 s where it left at 1.70 s, and the first card shows at 1.59 s where it showed at 1.92 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) });
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
87
src/test/utils/shared/earlyFetch.test.ts
Normal file
87
src/test/utils/shared/earlyFetch.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user