perf: dedupe site config client refreshes

This commit is contained in:
FengLee
2026-05-30 14:04:36 +08:00
parent 6cc30347a2
commit 9461531ff3
4 changed files with 81 additions and 19 deletions

View File

@@ -32,6 +32,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Footer content missing or not Markdown-rendered | `src/components/site-footer.tsx`, `src/components/site-policy-page.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Config response fields, Markdown renderer, fallback defaults, PUT persistence. |
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
| Clicking navbar between home/create/gallery/profile feels slow while server-side route TTFB is normal | `src/lib/site-config.ts`, `src/components/navbar.tsx`, `src/components/site-brand.tsx`, `src/components/site-footer.tsx`, `src/app/page.tsx` | Compare production local route timings with real browser navigation. If pages and `/api/site-config` are fast on the server but browser clicks still feel delayed, check whether multiple `useSiteConfig()` consumers are issuing duplicate concurrent config requests, and confirm the shared snapshot plus in-flight request dedupe in `src/lib/site-config.ts` is present. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. |
| Console reports CSP blocking `https://fonts.googleapis.cn/...` | `src/app/globals.css`, `src/proxy.ts` | `globals.css` imports Noto Serif SC from `fonts.googleapis.cn`; CSP must allow that stylesheet domain in `style-src` and the matching font CDN in `font-src`. |
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |

View File

@@ -13,7 +13,7 @@ Use this document to jump directly to code before broad searching.
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav should not include removed feature routes. Logged-in desktop/mobile user buttons should show `AuthUser.avatarUrl` first and fall back to the display nickname initial only when the avatar is missing or fails to load. |
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
| Announcement popup | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/app/globals.css` | Frontend popup behavior plus backend announcement CRUD. Desktop dialog is intentionally wide (`max-w-5xl`) for long Markdown notices; scrollbar styling is inherited from the global glass scrollbar rules. |
| Site config sync | `src/components/site-config-sync.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Site name, tab title, logo, favicon, policy Markdown, filing, membership switch. |
| Site config sync | `src/components/site-config-sync.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Site name, tab title, logo, favicon, policy Markdown, filing, membership switch. `useSiteConfig()` keeps a shared browser snapshot and reuses the in-flight `/api/site-config` refresh so global consumers such as navbar, footer, site-brand, and policy pages do not each create their own concurrent config request during navigation. |
| Visit tracking | `src/components/visit-tracker.tsx`, `src/app/api/site-stats/route.ts` | Public visit counter. |
| Security headers and iframe embedding | `src/proxy.ts`, `.env.example` | CSP is set in the Next proxy. `frame-ancestors` controls which external platforms may embed MiaoJing in an iframe; `MIAOJING_FRAME_ANCESTORS` can override the default self + mozheAPI allowlist. When external ancestors are allowed, do not send `X-Frame-Options: SAMEORIGIN`, because it blocks third-party iframes. |

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('site config hook shares in-flight refresh requests across global components', () => {
const source = read('src/lib/site-config.ts');
assert.match(source, /let siteConfigSnapshot: SiteConfig \| null = null;/);
assert.match(source, /let inFlightSiteConfigRequest: Promise<SiteConfig \| null> \| null = null;/);
assert.match(source, /function fetchFreshSiteConfig\(\): Promise<SiteConfig \| null>/);
assert.match(source, /if \(inFlightSiteConfigRequest\) return inFlightSiteConfigRequest;/);
assert.match(source, /inFlightSiteConfigRequest = null;/);
});
await runTest('site config hook uses a shared snapshot for instant repeated mounts', () => {
const source = read('src/lib/site-config.ts');
assert.match(source, /function getInitialSiteConfig\(\): \{ config: SiteConfig; loaded: boolean \}/);
assert.match(source, /if \(siteConfigSnapshot\) return \{ config: siteConfigSnapshot, loaded: true \};/);
assert.match(source, /siteConfigSnapshot = config;/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -48,6 +48,9 @@ interface CachedConfig {
timestamp: number;
}
let siteConfigSnapshot: SiteConfig | null = null;
let inFlightSiteConfigRequest: Promise<SiteConfig | null> | null = null;
function normalizeSiteConfig(data?: Partial<SiteConfig> | null): SiteConfig {
return {
siteName: data?.siteName || DEFAULT_SITE_CONFIG.siteName,
@@ -88,10 +91,37 @@ function setCachedConfig(config: SiteConfig) {
}
function publishConfig(config: SiteConfig) {
siteConfigSnapshot = config;
setCachedConfig(config);
window.dispatchEvent(new CustomEvent(EVENT_KEY, { detail: config }));
}
function getInitialSiteConfig(): { config: SiteConfig; loaded: boolean } {
if (siteConfigSnapshot) return { config: siteConfigSnapshot, loaded: true };
const cached = getCachedConfig();
if (cached) {
siteConfigSnapshot = cached;
return { config: cached, loaded: true };
}
return { config: DEFAULT_SITE_CONFIG, loaded: false };
}
function fetchFreshSiteConfig(): Promise<SiteConfig | null> {
if (inFlightSiteConfigRequest) return inFlightSiteConfigRequest;
inFlightSiteConfigRequest = fetch('/api/site-config')
.then(res => res.ok ? res.json() : null)
.then((data: SiteConfig | null) => {
if (!data) return null;
const merged = normalizeSiteConfig(data);
publishConfig(merged);
return merged;
})
.finally(() => {
inFlightSiteConfigRequest = null;
});
return inFlightSiteConfigRequest;
}
function getAuthToken(): string | null {
try {
const raw = localStorage.getItem('miaojing_auth');
@@ -107,31 +137,24 @@ function getAuthToken(): string | null {
* Falls back to localStorage cache, then defaults.
*/
export function useSiteConfig() {
const [config, setConfig] = useState<SiteConfig>(DEFAULT_SITE_CONFIG);
const [loaded, setLoaded] = useState(false);
const [config, setConfig] = useState<SiteConfig>(() => getInitialSiteConfig().config);
const [loaded, setLoaded] = useState(() => getInitialSiteConfig().loaded);
useEffect(() => {
// Try cache first for instant render
const cached = getCachedConfig();
if (cached) {
setConfig(cached);
setLoaded(true);
}
// Always fetch fresh from server
fetch('/api/site-config')
.then(res => res.ok ? res.json() : null)
.then((data: SiteConfig | null) => {
if (data) {
const merged = normalizeSiteConfig(data);
setConfig(merged);
publishConfig(merged);
}
let cancelled = false;
fetchFreshSiteConfig()
.then((data) => {
if (cancelled) return;
if (data) setConfig(data);
setLoaded(true);
})
.catch(() => {
if (cancelled) return;
setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {