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:
@@ -12,6 +12,7 @@ import { AuthContext } from "../../contexts/auth/AuthContext.ts";
|
|||||||
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
|
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
|
||||||
import type { UserSettings } from "../../../shared/settings.ts";
|
import type { UserSettings } from "../../../shared/settings.ts";
|
||||||
import type { Session } from "../../utils/auth/storage.ts";
|
import type { Session } from "../../utils/auth/storage.ts";
|
||||||
|
import { takeEarly } from "../../utils/shared/earlyFetch.ts";
|
||||||
import {
|
import {
|
||||||
forgetStoredSession,
|
forgetStoredSession,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
@@ -113,7 +114,13 @@ export function useAuth(): UseAuthResult {
|
|||||||
|
|
||||||
const isFormData = init.body instanceof FormData;
|
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,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
@@ -121,7 +128,7 @@ export function useAuth(): UseAuthResult {
|
|||||||
// The browser sets FormData's Content-Type, with its boundary.
|
// The browser sets FormData's Content-Type, with its boundary.
|
||||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { isLocale, type Locale } from "../shared/locale.ts";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the stored locale, else French for a French browser, else English.
|
* 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 {
|
function detectLocale(): Locale {
|
||||||
const stored = localStorage.getItem("locale");
|
const stored = localStorage.getItem("locale");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Starts the frontend. It loads the stylesheets and the interface's language,
|
* 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
|
* 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,
|
* 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 { App } from "./App.tsx";
|
||||||
import { loadCatalog } from "./i18n.ts";
|
import { loadCatalog } from "./i18n.ts";
|
||||||
|
import { startLandingRequest } from "./utils/shared/earlyFetch.ts";
|
||||||
|
|
||||||
import "./fonts.css";
|
import "./fonts.css";
|
||||||
import "./tokens.css";
|
import "./tokens.css";
|
||||||
@@ -42,6 +44,10 @@ globalThis.addEventListener("vite:preloadError", (event) => {
|
|||||||
location.reload();
|
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.
|
// Before the first render, or it would show untranslated message ids.
|
||||||
await loadCatalog();
|
await loadCatalog();
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
|
|||||||
import { type FeedTab, type FollowView } from "../../shared/paths.ts";
|
import { type FeedTab, type FollowView } from "../../shared/paths.ts";
|
||||||
import { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.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 { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
|
||||||
import type { Dump } from "../../shared/dump.ts";
|
import type { Dump } from "../../shared/dump.ts";
|
||||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||||
@@ -84,9 +83,7 @@ export function Index(
|
|||||||
const defaultTab = useViewerDefaultFeedTab();
|
const defaultTab = useViewerDefaultFeedTab();
|
||||||
const tab: FeedTab = feedTab ?? defaultTab;
|
const tab: FeedTab = feedTab ?? defaultTab;
|
||||||
|
|
||||||
const feedUrl = categorySlug
|
const feedUrl = feedApiUrl(categorySlug);
|
||||||
? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}`
|
|
||||||
: `${API_URL}/api/dumps`;
|
|
||||||
const fetchPage = useCallback(
|
const fetchPage = useCallback(
|
||||||
(page: number, signal?: AbortSignal) =>
|
(page: number, signal?: AbortSignal) =>
|
||||||
authFetch(pageUrl(feedUrl, page), { signal })
|
authFetch(pageUrl(feedUrl, page), { signal })
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts";
|
|||||||
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
|
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
|
||||||
import { calls, route } from "../../fetch.ts";
|
import { calls, route } from "../../fetch.ts";
|
||||||
import { makeSession, makeToken, storeSession } from "../../fixtures.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) {
|
function wrapperFor(initial: Session | null) {
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
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 () => {
|
test("no Authorization header when signed out", async () => {
|
||||||
route("GET /api/open", () => ({}));
|
route("GET /api/open", () => ({}));
|
||||||
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
|
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { i18n } from "@lingui/core";
|
|||||||
import { messages } from "../../locales/en.po";
|
import { messages } from "../../locales/en.po";
|
||||||
import { FakeWebSocket } from "./ws.ts";
|
import { FakeWebSocket } from "./ws.ts";
|
||||||
import { resetFetch } from "./fetch.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
|
// 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.
|
// the default 1s too tight once every file runs in parallel.
|
||||||
@@ -179,6 +180,7 @@ document.execCommand ??= () => false;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetFetch();
|
resetFetch();
|
||||||
|
clearEarlyRequests();
|
||||||
FakeWebSocket.reset();
|
FakeWebSocket.reset();
|
||||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
FakeIntersectionObserver.instances = [];
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/utils/shared/earlyFetch.ts
Normal file
95
src/utils/shared/earlyFetch.ts
Normal file
@@ -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<Response>; 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<Response> | 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();
|
||||||
|
}
|
||||||
@@ -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
|
* 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.
|
* has no http(s) scheme. The result is not checked to be a valid URL.
|
||||||
|
|||||||
@@ -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<string, string> = {};
|
||||||
|
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
|
// 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.
|
// one dependency cache, two of them would rewrite it under each other.
|
||||||
const INSTANCE = process.env.GERBEUR_INSTANCE;
|
const INSTANCE = process.env.GERBEUR_INSTANCE;
|
||||||
@@ -166,6 +202,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
emojibase(),
|
emojibase(),
|
||||||
|
preloadCatalog(),
|
||||||
precompress(),
|
precompress(),
|
||||||
lingui(),
|
lingui(),
|
||||||
react({
|
react({
|
||||||
|
|||||||
Reference in New Issue
Block a user