perf: dedupe site config client refreshes
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
38
scripts/test-site-config-client-cache.mjs
Normal file
38
scripts/test-site-config-client-cache.mjs
Normal 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);
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user