perf: reduce site config refresh overhead

This commit is contained in:
FengLee
2026-06-05 22:40:06 +08:00
parent 79f00aa8f2
commit a0c8c128a2
5 changed files with 49 additions and 4 deletions

View File

@@ -32,7 +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. |
| Clicking navbar between home/create/gallery/profile feels slow while server-side route TTFB is normal | `src/lib/site-config.ts`, `src/app/api/site-config/route.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, 5-minute fresh-cache network skip, and in-flight request dedupe in `src/lib/site-config.ts` are present. Also confirm `/api/site-config` is not running schema/default compatibility DDL on every GET; it should cache that check once per server process and retry only after failure. 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. `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. |
| 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, skips network refresh while that snapshot is still within the 5-minute TTL, 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. The API route runs legacy schema/default compatibility checks once per server process and retries on failure. |
| 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

@@ -35,4 +35,22 @@ await runTest('site config hook uses a shared snapshot for instant repeated moun
assert.match(source, /siteConfigSnapshot = config;/);
});
await runTest('site config hook skips fresh-cache network refreshes on route remounts', () => {
const source = read('src/lib/site-config.ts');
assert.match(source, /let siteConfigSnapshotTimestamp = 0;/);
assert.match(source, /function isSiteConfigSnapshotFresh\(\): boolean/);
assert.match(source, /if \(isSiteConfigSnapshotFresh\(\)\) \{/);
assert.match(source, /siteConfigSnapshotTimestamp = Date\.now\(\);/);
});
await runTest('site config API caches schema compatibility checks after startup', () => {
const source = read('src/app/api/site-config/route.ts');
assert.match(source, /let siteConfigColumnsReady = false;/);
assert.match(source, /let siteConfigColumnsPromise: Promise<void> \| null = null;/);
assert.match(source, /async function ensureSiteConfigColumnsOnce/);
assert.match(source, /if \(siteConfigColumnsReady\) return;/);
assert.match(source, /siteConfigColumnsReady = true;/);
assert.match(source, /await ensureSiteConfigColumnsOnce\(client\);/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -41,6 +41,9 @@ type SiteConfigRow = {
log_retention_days?: number | null;
};
let siteConfigColumnsReady = false;
let siteConfigColumnsPromise: Promise<void> | null = null;
async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbClient>>) {
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS membership_enabled BOOLEAN NOT NULL DEFAULT TRUE');
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS terms_of_service TEXT NOT NULL DEFAULT ''");
@@ -60,6 +63,20 @@ async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbCl
await client.query("UPDATE site_config SET help_center = $1 WHERE help_center = ''", [DEFAULT_HELP_CENTER]);
}
async function ensureSiteConfigColumnsOnce(client: Awaited<ReturnType<typeof getDbClient>>) {
if (siteConfigColumnsReady) return;
if (!siteConfigColumnsPromise) {
siteConfigColumnsPromise = ensureSiteConfigColumns(client)
.then(() => {
siteConfigColumnsReady = true;
})
.finally(() => {
siteConfigColumnsPromise = null;
});
}
await siteConfigColumnsPromise;
}
function normalizeResponse(data?: SiteConfigRow | null) {
return {
siteName: data?.site_name || DEFAULT_RESPONSE.siteName,
@@ -120,7 +137,7 @@ export async function GET() {
try {
const client = await getDbClient();
try {
await ensureSiteConfigColumns(client);
await ensureSiteConfigColumnsOnce(client);
const result = await client.query(
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days FROM site_config WHERE id = 1'
);
@@ -168,7 +185,7 @@ export async function PUT(request: NextRequest) {
const client = await getDbClient();
try {
await ensureSiteConfigColumns(client);
await ensureSiteConfigColumnsOnce(client);
const updates: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;

View File

@@ -49,6 +49,7 @@ interface CachedConfig {
}
let siteConfigSnapshot: SiteConfig | null = null;
let siteConfigSnapshotTimestamp = 0;
let inFlightSiteConfigRequest: Promise<SiteConfig | null> | null = null;
function normalizeSiteConfig(data?: Partial<SiteConfig> | null): SiteConfig {
@@ -77,6 +78,7 @@ function getCachedConfig(): SiteConfig | null {
if (!raw) return null;
const cached: CachedConfig = JSON.parse(raw);
if (Date.now() - cached.timestamp < CACHE_TTL) {
siteConfigSnapshotTimestamp = cached.timestamp;
return normalizeSiteConfig(cached.data);
}
} catch { /* ignore */ }
@@ -92,10 +94,15 @@ function setCachedConfig(config: SiteConfig) {
function publishConfig(config: SiteConfig) {
siteConfigSnapshot = config;
siteConfigSnapshotTimestamp = Date.now();
setCachedConfig(config);
window.dispatchEvent(new CustomEvent(EVENT_KEY, { detail: config }));
}
function isSiteConfigSnapshotFresh(): boolean {
return Boolean(siteConfigSnapshot) && Date.now() - siteConfigSnapshotTimestamp < CACHE_TTL;
}
function getInitialSiteConfig(): { config: SiteConfig; loaded: boolean } {
if (siteConfigSnapshot) return { config: siteConfigSnapshot, loaded: true };
const cached = getCachedConfig();
@@ -107,6 +114,9 @@ function getInitialSiteConfig(): { config: SiteConfig; loaded: boolean } {
}
function fetchFreshSiteConfig(): Promise<SiteConfig | null> {
if (isSiteConfigSnapshotFresh()) {
return Promise.resolve(siteConfigSnapshot);
}
if (inFlightSiteConfigRequest) return inFlightSiteConfigRequest;
inFlightSiteConfigRequest = fetch('/api/site-config')
.then(res => res.ok ? res.json() : null)