From 60f89af3a2757feb6aee768ad04b1e7a63cc0711 Mon Sep 17 00:00:00 2001 From: Haseeb MernStack Date: Wed, 28 Oct 2026 11:44:45 +0500 Subject: [PATCH] perf: faster session restore, code splitting, edge caching, SEO and a11y fixes --- apps/api/src/controllers/publicController.ts | 3 +- apps/web/index.html | 50 +++++++- apps/web/public/robots.txt | 7 + apps/web/public/sitemap.xml | 8 ++ apps/web/src/App.tsx | 87 ++++++++++--- apps/web/src/components/AdSlot.tsx | 29 ++++- apps/web/src/components/PostCard.tsx | 1 + apps/web/src/components/PostList.tsx | 8 +- apps/web/src/lib/api.ts | 127 +++++++++++++------ apps/web/src/store/auth.tsx | 64 ++++++++-- apps/web/src/styles/global.css | 69 ++++++++++ apps/web/vite.config.ts | 13 ++ docs/PERFORMANCE.md | 19 +++ 13 files changed, 405 insertions(+), 80 deletions(-) create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/public/sitemap.xml create mode 100644 docs/PERFORMANCE.md diff --git a/apps/api/src/controllers/publicController.ts b/apps/api/src/controllers/publicController.ts index 7aa46e6..5d03f0e 100644 --- a/apps/api/src/controllers/publicController.ts +++ b/apps/api/src/controllers/publicController.ts @@ -27,6 +27,7 @@ export async function listPosts(_req: Request, res: Response) { items: posts.map((p) => toPostDTO(p, false, true)), nextCursor: hasMore ? (posts[posts.length - 1]?._id.toString() ?? null) : null, }; - res.set("Cache-Control", "public, max-age=30"); + // Everyone gets the same answer, so the CDN may serve it for a minute and refresh in the background. + res.set("Cache-Control", "public, max-age=30, s-maxage=60, stale-while-revalidate=300"); res.json(body); } diff --git a/apps/web/index.html b/apps/web/index.html index e2f67ad..f77d16f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,19 +4,19 @@ - Commune + Commune: an open source community platform - - - + + + @@ -33,16 +33,54 @@ + + + + + + -

Loading Commune…

+
+
Commune
+
+

Commune

+

An open source community platform. Share posts, follow people, join communities and read the latest news.

+
+
diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..252edb8 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,7 @@ +User-agent: * +Allow: / +Disallow: /api/ +Disallow: /messages +Disallow: /admin + +Sitemap: https://commune-mu.vercel.app/sitemap.xml diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml new file mode 100644 index 0000000..7a2d5a9 --- /dev/null +++ b/apps/web/public/sitemap.xml @@ -0,0 +1,8 @@ + + + https://commune-mu.vercel.app/hourly1.0 + https://commune-mu.vercel.app/explorehourly0.9 + https://commune-mu.vercel.app/newshourly0.7 + https://commune-mu.vercel.app/contactyearly0.3 + https://commune-mu.vercel.app/registeryearly0.5 + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fdd2511..3eec4cb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,30 +1,75 @@ import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { Suspense, lazy, useEffect } from "react"; import type { ReactNode } from "react"; import { useAuth } from "./store/auth"; import Header, { AUTH_PATHS } from "./components/Header"; import FeedPage from "./pages/FeedPage"; -import PostPage from "./pages/PostPage"; -import LoginPage from "./pages/LoginPage"; -import RegisterPage from "./pages/RegisterPage"; -import ProfilePage from "./pages/ProfilePage"; -import NotificationsPage from "./pages/NotificationsPage"; -import AdminPage from "./pages/AdminPage"; -import ForgotPasswordPage from "./pages/ForgotPasswordPage"; -import ResetPasswordPage from "./pages/ResetPassword"; -import VerifyEmailPage from "./pages/VerifyEmailPage"; import VerifyBanner from "./components/VerifyBanner"; -import SearchPage from "./pages/SearchPage"; import PwaBanner from "./components/PwaBanner"; import ExplorePage from "./pages/ExplorePage"; -import CommunitiesPage from "./pages/CommunitiesPage"; -import CommunityPage from "./pages/CommunityPage"; import LeftNav from "./components/LeftNav"; import RightRail from "./components/RightRail"; -import MessagesPage from "./pages/MessagesPage"; -import NewsPage from "./pages/NewsPage"; -import ContactPage from "./pages/ContactPage"; -import PlusPage from "./pages/PlusPage"; -import InsightsPage from "./pages/InsightsPage"; + +const PostPage = lazy(() => import("./pages/PostPage")); +const LoginPage = lazy(() => import("./pages/LoginPage")); +const RegisterPage = lazy(() => import("./pages/RegisterPage")); +const ProfilePage = lazy(() => import("./pages/ProfilePage")); +const NotificationsPage = lazy(() => import("./pages/NotificationsPage")); +const AdminPage = lazy(() => import("./pages/AdminPage")); +const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage")); +const VerifyEmailPage = lazy(() => import("./pages/VerifyEmailPage")); +const SearchPage = lazy(() => import("./pages/SearchPage")); +const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage")); +const CommunityPage = lazy(() => import("./pages/CommunityPage")); +const PlusPage = lazy(() => import("./pages/PlusPage")); +const InsightsPage = lazy(() => import("./pages/InsightsPage")); +const MessagesPage = lazy(() => import("./pages/MessagesPage")); +const NewsPage = lazy(() => import("./pages/NewsPage")); +const ContactPage = lazy(() => import("./pages/ContactPage")); +const ResetPasswordPage = lazy(() => import("./pages/ResetPassword")); + +const TITLES: Record = { + "/": "Commune: an open source community platform", + "/explore": "Explore", + "/news": "Latest news", + "/contact": "Contact", + "/plus": "Commune Plus", + "/insights": "Insights", + "/messages": "Messages", + "/communities": "Communities", + "/notifications": "Notifications", + "/search": "Search", + "/login": "Log in", + "/register": "Sign up", + "/forgot-password": "Forgot password", + "/reset-password": "Reset password", + "/admin": "Admin", +}; + +/** Per-page browser title and canonical link, so tabs, history and search engines get real names. */ +function RouteMeta() { + const { pathname } = useLocation(); + useEffect(() => { + const user = /^\/u\/([^/]+)/.exec(pathname)?.[1]; + const community = /^\/c\/([^/]+)/.exec(pathname)?.[1]; + const name = + TITLES[pathname] ?? + (user ? `@${decodeURIComponent(user)}` : community ? `c/${decodeURIComponent(community)}` : pathname.startsWith("/messages") ? "Messages" : pathname.startsWith("/posts/") ? "Post" : "Commune"); + document.title = pathname === "/" ? name : `${name} · Commune`; + + let link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement("link"); + link.rel = "canonical"; + document.head.appendChild(link); + } + link.href = `${window.location.origin}${pathname}`; + + // Move keyboard and screen-reader focus to the page content after navigating. + document.getElementById("main")?.focus({ preventScroll: true }); + }, [pathname]); + return null; +} function Protected({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); @@ -47,17 +92,22 @@ export default function App() { const wide = pathname.startsWith("/messages"); return ( <> + + Skip to content + +
{!authPage && }
{!authPage && user && } -
+
{authPage && ( Commune )} + Loading…

}> } /> } /> @@ -159,6 +209,7 @@ export default function App() { /> } /> +
{!authPage && !wide && }
diff --git a/apps/web/src/components/AdSlot.tsx b/apps/web/src/components/AdSlot.tsx index 4a96768..ce96537 100644 --- a/apps/web/src/components/AdSlot.tsx +++ b/apps/web/src/components/AdSlot.tsx @@ -15,14 +15,33 @@ declare global { let scriptAdded = false; +/** + * The AdSense script is heavy, so it waits until the page has finished loading and the + * visitor has done something (scroll, tap, key press), or 8 seconds have passed. The page + * itself therefore loads and responds first. + */ function loadAdSense(client: string) { if (scriptAdded) return; scriptAdded = true; - const script = document.createElement("script"); - script.async = true; - script.crossOrigin = "anonymous"; - script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`; - document.head.appendChild(script); + const add = () => { + const script = document.createElement("script"); + script.async = true; + script.crossOrigin = "anonymous"; + script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`; + document.head.appendChild(script); + }; + const events = ["scroll", "pointerdown", "keydown", "touchstart"] as const; + let done = false; + const go = () => { + if (done) return; + done = true; + events.forEach((e) => window.removeEventListener(e, go)); + add(); + }; + events.forEach((e) => window.addEventListener(e, go, { once: true, passive: true })); + const afterLoad = () => window.setTimeout(go, 8000); + if (document.readyState === "complete") afterLoad(); + else window.addEventListener("load", afterLoad, { once: true }); } export default function AdSlot({ slot }: { slot: string | undefined }) { diff --git a/apps/web/src/components/PostCard.tsx b/apps/web/src/components/PostCard.tsx index 83a17f7..335a4b7 100644 --- a/apps/web/src/components/PostCard.tsx +++ b/apps/web/src/components/PostCard.tsx @@ -53,6 +53,7 @@ export default function PostCard({ post }: { post: PostDTO }) { src={assetUrl(post.imageUrl)} alt={`Image posted by @${post.author.username}`} loading="lazy" + decoding="async" /> )} diff --git a/apps/web/src/components/PostList.tsx b/apps/web/src/components/PostList.tsx index f2fa671..f5458ef 100644 --- a/apps/web/src/components/PostList.tsx +++ b/apps/web/src/components/PostList.tsx @@ -13,7 +13,13 @@ export default function PostList({ return ( <> - {query.isLoading &&

Loading…

} + {query.isLoading && ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ )} {query.isError &&

{query.error.message}

} {query.isSuccess && posts.length === 0 &&

{emptyMessage}

} {posts.map((post) => ( diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 93c73f7..0e4c244 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,72 +1,127 @@ import type { AuthResponse } from "@commune/shared"; -// Production builds call the same origin ("/api"), which Vercel forwards to the API (see -// apps/web/vercel.json). That keeps the login cookie first-party. Local dev talks to :4000. -const BASE = - import.meta.env.VITE_API_URL ?? (import.meta.env.PROD ? "/api" : "http://localhost:4000/api"); +// Production builds always call the same origin ("/api"), which Vercel forwards to the API +// (see apps/web/vercel.json). That keeps the login cookie first-party. VITE_API_URL is only +// used in development, so a stray value in the hosting dashboard can't break a deployed site. +const BASE = import.meta.env.PROD + ? "/api" + : (import.meta.env.VITE_API_URL ?? "http://localhost:4000/api"); // Uploaded files are served from the API origin, e.g. http://localhost:4000/uploads/abc.png. // With a relative VITE_API_URL such as "/api" this is "", so "/uploads/..." stays relative. const ORIGIN = BASE.replace(/\/api\/?$/, ""); let accessToken: string | null = null; -let refreshing: Promise | null = null; +let refreshing: Promise | null = null; export function setAccessToken(token: string | null) { accessToken = token; } -/** Turns a stored upload path like "/uploads/x.png" into a URL the browser can load. */ -export function assetUrl(path: string | null | undefined): string | undefined { - return path ? `${ORIGIN}${path}` : undefined; +// ---- Session hint: lets the app skip, or start earlier, the "am I logged in?" round trip ---- +// "1" = this browser has a session, "0" = known guest, missing = unknown (ask the server once). +// The hint is never trusted for access; the server still checks every request. +const HINT_KEY = "commune_session"; +export type SessionHint = "1" | "0" | null; + +export function readSessionHint(): SessionHint { + try { + const v = localStorage.getItem(HINT_KEY); + return v === "1" || v === "0" ? v : null; + } catch { + return null; + } } -export class ApiError extends Error { - constructor( - public status: number, - message: string, - ) { - super(message); +export function writeSessionHint(hint: "1" | "0") { + try { + localStorage.setItem(HINT_KEY, hint); + } catch { + /* private mode or blocked storage: the app just works without the shortcut */ } } -async function refresh(): Promise { - refreshing ??= fetch(`${BASE}/auth/refresh`, { - method: "POST", - credentials: "include", - }) +// index.html starts a few requests before this script has even downloaded (news, public +// posts, session refresh). Take the response over once instead of asking the server again. +type EarlyMap = Record | undefined>; +function takeEarly(path: string): Promise | undefined { + const map = (window as unknown as { __early?: EarlyMap }).__early; + const hit = map?.[path]; + if (map) delete map[path]; + return hit; +} + +async function refreshSession(): Promise { + refreshing ??= (takeEarly("/auth/refresh") ?? fetch(`${BASE}/auth/refresh`, { method: "POST", credentials: "include" })) .then(async (res) => { - if (!res.ok) return false; + if (!res.ok) return null; const data = (await res.json()) as AuthResponse; accessToken = data.accessToken; - return true; + return data; }) - .catch(() => false) + .catch(() => null) .finally(() => { refreshing = null; }); return refreshing; } +async function refresh(): Promise { + return (await refreshSession()) !== null; +} + +// Starts at page load (not after React renders) unless this browser is a known guest. +let boot: Promise | null = readSessionHint() === "0" ? null : refreshSession(); +void boot?.finally(() => { + boot = null; +}); +const bootPromise = boot; +/** Resolves with the restored session (or null). Used once by the AuthProvider. */ +export const bootSession = (): Promise => bootPromise ?? Promise.resolve(null); + +/** Turns a stored upload path like "/uploads/x.png" into a URL the browser can load. */ +export function assetUrl(path: string | null | undefined): string | undefined { + return path ? `${ORIGIN}${path}` : undefined; +} + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + export async function api( path: string, options: { method?: string; body?: unknown } = {}, retry = true, ): Promise { const isForm = options.body instanceof FormData; - const res = await fetch(`${BASE}${path}`, { - method: options.method ?? "GET", - credentials: "include", - headers: { - // For FormData the browser sets the multipart Content-Type (with boundary) itself. - ...(options.body && !isForm ? { "Content-Type": "application/json" } : {}), - ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), - }, - body: options.body - ? isForm - ? (options.body as FormData) - : JSON.stringify(options.body) - : undefined, - }); + // Requests that need a login wait for the session restore that is already in flight, instead + // of going out without a token, failing with 401 and being repeated. + if (boot && !accessToken && !path.startsWith("/auth/")) await boot; + + const send = () => + fetch(`${BASE}${path}`, { + method: options.method ?? "GET", + credentials: "include", + headers: { + // For FormData the browser sets the multipart Content-Type (with boundary) itself. + ...(options.body && !isForm ? { "Content-Type": "application/json" } : {}), + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: options.body + ? isForm + ? (options.body as FormData) + : JSON.stringify(options.body) + : undefined, + }); + + // A plain GET may already have been started by index.html; use that response if it worked. + const early = !options.method && !options.body && retry ? takeEarly(path) : undefined; + const earlyRes = early ? await early.catch(() => undefined) : undefined; + const res = earlyRes?.ok ? earlyRes : await send(); if (res.status === 401 && retry && !path.startsWith("/auth/")) { if (await refresh()) return api(path, options, false); diff --git a/apps/web/src/store/auth.tsx b/apps/web/src/store/auth.tsx index d841df4..90d9b5f 100644 --- a/apps/web/src/store/auth.tsx +++ b/apps/web/src/store/auth.tsx @@ -1,7 +1,27 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import type { AuthResponse, LoginInput, PublicUser, RegisterInput } from "@commune/shared"; -import { api, setAccessToken, tryRefresh } from "../lib/api"; +import { api, bootSession, readSessionHint, setAccessToken, writeSessionHint } from "../lib/api"; + +// The last known profile, shown instantly on the next visit while the session is re-checked. +const USER_KEY = "commune_user"; +function readCachedUser(): PublicUser | null { + if (readSessionHint() !== "1") return null; + try { + const raw = localStorage.getItem(USER_KEY); + return raw ? (JSON.parse(raw) as PublicUser) : null; + } catch { + return null; + } +} +function cacheUser(user: PublicUser | null) { + try { + if (user) localStorage.setItem(USER_KEY, JSON.stringify(user)); + else localStorage.removeItem(USER_KEY); + } catch { + /* ignore */ + } +} interface AuthState { user: PublicUser | null; @@ -10,30 +30,47 @@ interface AuthState { register: (input: RegisterInput) => Promise; loginWithGoogle: (credential: string) => Promise; logout: () => Promise; - setUser: (user: PublicUser) => void; + setUser: (user: PublicUser | null) => void; } const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + // With a cached profile the app renders as logged in straight away (left menu, feed shell) + // and the session is confirmed in the background. Known guests never wait at all. + const [user, setUserState] = useState(readCachedUser); + // Wait only when there is nothing to show yet: first visit (unknown) or a session without a cached profile. + const [loading, setLoading] = useState(() => { + const hint = readSessionHint(); + return hint === null || (hint === "1" && readCachedUser() === null); + }); + + const setUser = useCallback((next: PublicUser | null) => { + cacheUser(next); + setUserState(next); + }, []); useEffect(() => { - (async () => { - if (await tryRefresh()) { - try { - setUser(await api("/users/me")); - } catch { - setUser(null); - } + let alive = true; + void bootSession().then((res) => { + if (!alive) return; + if (res) { + writeSessionHint("1"); + setUser(res.user); + } else { + writeSessionHint("0"); + setUser(null); } setLoading(false); - })(); - }, []); + }); + return () => { + alive = false; + }; + }, [setUser]); const apply = (res: AuthResponse) => { setAccessToken(res.accessToken); + writeSessionHint("1"); setUser(res.user); }; @@ -52,6 +89,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = useCallback(async () => { await api("/auth/logout", { method: "POST" }).catch(() => undefined); setAccessToken(null); + writeSessionHint("0"); setUser(null); }, []); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 0936d75..db73930 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -1687,3 +1687,72 @@ video { display: inline-flex; } } + +/* ===================================================================== + Performance and accessibility + ===================================================================== */ +:root { + --muted: #5f5c55; /* 6:1 on the page background (was 4.6:1) */ +} + +@media (prefers-color-scheme: dark) { + :root { + --muted: #a6a398; + } +} + +::placeholder { + color: var(--muted); + opacity: 1; +} + +main:focus { + outline: none; +} + +.skip-link { + position: absolute; + left: 8px; + top: -60px; + z-index: 100; + padding: 10px 16px; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 600; +} + +.skip-link:focus { + top: 8px; +} + +/* Placeholders keep the page from jumping while content loads. */ +.skeleton-post { + height: 120px; + margin-bottom: 12px; + background: linear-gradient(90deg, var(--surface), var(--border), var(--surface)); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; +} + +.route-loading { + min-height: 60vh; +} + +.news-panel { + min-height: 420px; +} + +/* Posts far below the screen are not drawn until they are close. */ +.post { + content-visibility: auto; + contain-intrinsic-size: auto 160px; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton, + .skeleton-post, + .live-dot::before { + animation: none; + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 08ec10d..cbd6575 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -12,4 +12,17 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { "@commune/shared": sharedEntry } }, server: { port: 5173 }, + build: { + target: "es2020", + // Long-lived libraries go in their own files so they stay cached between deployments + // while only the (small, changing) app code is downloaded again. + rollupOptions: { + output: { + manualChunks: { + react: ["react", "react-dom", "react-router-dom"], + query: ["@tanstack/react-query"], + }, + }, + }, + }, }); diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..ae3d7d1 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,19 @@ +# Performance notes + +What makes the first screen fast, and where to change it: + +- **Session restore starts at page load.** `index.html` fires the session refresh, the news request and + (for visitors) the public posts request before the app has downloaded; `src/lib/api.ts` takes those + responses over. Browsers remember a session hint (`commune_session`) and the last profile + (`commune_user`), so the left menu and feed shell appear instantly and the session is confirmed in the + background. Logged-out visitors skip the check entirely. +- **Code splitting.** Only the feed and Explore pages are in the first download; every other page loads + when opened (`React.lazy` in `App.tsx`). React, the router and React Query are separate cached files. +- **CDN caching.** `/api/news` and `/api/public/posts` send `s-maxage` so Vercel's edge can answer without + waking the API. +- **Ads load last.** AdSense waits for the first scroll/tap/key press (or 8 seconds after load). +- **Layout stays still.** Placeholders for posts and news, `content-visibility` for long feeds. +- **SEO.** `robots.txt`, `sitemap.xml` (update the domain in both when you add a custom domain), a canonical + link and a title per page. + +Measure with Lighthouse in an incognito window, mobile mode, on the deployed site (not localhost).