perf: restore instant create navigation

This commit is contained in:
FengLee
2026-06-06 16:35:42 +08:00
parent dd6b0079bb
commit f21c668d9e
6 changed files with 19 additions and 64 deletions

View File

@@ -76,7 +76,7 @@ Mobile adaptation is handled primarily through page-level structure classes plus
Client stores in `src/lib/*-store.ts` mediate API calls and local UI state. When fixing a UI persistence bug, inspect both the component and the matching store/API route.
Navigation performance is handled as part of the frontend architecture, not only by backend route timing. `src/components/navbar.tsx` prefetches the core public routes after browser idle time and defers its initial logged-in profile refresh so a fresh page load does not compete with the user's first navigation. `src/components/visit-tracker.tsx` posts site statistics with `keepalive` after idle time because analytics should not block first paint. `src/app/create/page.tsx` keeps each creation workflow panel behind `next/dynamic`, so loading the create center does not eagerly initialize every image, video, and reverse-prompt workflow. `src/app/profile/page.tsx` keeps the parent account shell light and lets creation history, credit records, and orders mount their stores only inside their respective tab components. Keep `scripts/test-navigation-performance-policy.mjs` aligned with these constraints when changing the app shell, create center, or profile page.
Navigation performance is handled as part of the frontend architecture, not only by backend route timing. `src/components/navbar.tsx` defers its initial logged-in profile refresh so a fresh page load does not compete with the user's first navigation, but it should not eagerly prefetch every core route from the navbar because production web users can see that extra resource pressure as slower visible page switches. `src/components/visit-tracker.tsx` posts site statistics with `keepalive` after idle time because analytics should not block first paint. `src/app/create/page.tsx` keeps the primary creation workflow panels in the initial client bundle so switching between text/image/video/reverse-prompt modes does not wait on extra `ssr:false` chunks or show fallback flashes. `src/app/profile/page.tsx` keeps the parent account shell light and lets creation history, credit records, and orders mount their stores only inside their respective tab components. Keep `scripts/test-navigation-performance-policy.mjs` aligned with these constraints when changing the app shell, create center, or profile page.
## API Architecture

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/app/api/site-config/route.ts`, `src/components/navbar.tsx`, `src/components/site-brand.tsx`, `src/components/site-footer.tsx`, `src/app/page.tsx`, `src/app/create/page.tsx`, `src/app/profile/page.tsx`, `src/components/visit-tracker.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. Then verify `Navbar` idle-prefetches core routes, `VisitTracker` defers `/api/site-stats`, `/create` uses `next/dynamic` for the five heavy creation panels, and `/profile` does not mount creation-history, credit-record, or order stores from the parent page. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. Run `node --no-warnings ./scripts/test-navigation-performance-policy.mjs` after changing this area. |
| 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`, `src/app/create/page.tsx`, `src/app/profile/page.tsx`, `src/components/visit-tracker.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. Then verify `Navbar` is not doing eager all-route `router.prefetch(...)`, `VisitTracker` defers `/api/site-stats`, `/create` does not put the five primary creation panels behind `ssr:false` dynamic chunks, and `/profile` does not mount creation-history, credit-record, or order stores from the parent page. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. Run `node --no-warnings ./scripts/test-navigation-performance-policy.mjs` after changing this area. |
| 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

@@ -10,7 +10,7 @@ Use this document to jump directly to code before broad searching.
| --- | --- | --- |
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, full-width page mounting, and transient scrollbar visibility. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars are hidden by default and briefly show the rounded glass style when wheel/touch scrolling adds `scrollbars-visible` on `<html>`. |
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
| 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. Core routes (`/`, `/create`, `/gallery`, `/profile`) are prefetched after browser idle time, and the initial logged-in profile refresh is also idle and one-shot so it does not compete with the first route transition. |
| 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. Avoid eager all-route `router.prefetch(...)` from the navbar; on production web it can compete with visible route/resource loading. The initial logged-in profile refresh is idle and one-shot so it does not compete with the first route transition. |
| 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, 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. |
@@ -49,7 +49,7 @@ Use this document to jump directly to code before broad searching.
| Feature | Primary Files | Server/API Files |
| --- | --- | --- |
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. The five heavy creation panels are loaded through `next/dynamic` with a shared lightweight fallback; keep this split so opening `/create` does not eagerly bundle image, video, and reverse-prompt workflows at once. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. Keep the five primary creation panels statically imported in this page: production users switch between these modes constantly, and `ssr:false` dynamic splitting adds visible chunk waits and fallback flashes on direct web access. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while other active tasks run; duplicate in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column and expose a cancel action that calls `PATCH /api/generation-jobs/[id]`. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt分享到画廊会携带所有参考图和标注。 |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, running tasks can be cancelled, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |

View File

@@ -19,23 +19,20 @@ function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('create page lazy-loads heavy creation panels instead of bundling every mode upfront', () => {
await runTest('create page keeps primary creation panels in the initial client bundle for instant mode switches', () => {
const source = read('src/app/create/page.tsx');
for (const component of ['TextToImagePanel', 'ImageToImagePanel', 'TextToVideoPanel', 'ImageToVideoPanel', 'ReversePromptPanel']) {
assert.match(source, new RegExp(`const\\s+${component}\\s*=\\s*dynamic\\(`), `${component} should be imported through next/dynamic`);
}
assert.doesNotMatch(source, /import\s+\{\s*TextToImagePanel\s*\}\s+from\s+'@\/components\/create\/text-to-image'/);
assert.doesNotMatch(source, /import\s+\{\s*ImageToImagePanel\s*\}\s+from\s+'@\/components\/create\/image-to-image'/);
assert.doesNotMatch(source, /import\s+\{\s*TextToVideoPanel\s*\}\s+from\s+'@\/components\/create\/text-to-video'/);
assert.doesNotMatch(source, /import\s+\{\s*ImageToVideoPanel\s*\}\s+from\s+'@\/components\/create\/image-to-video'/);
assert.match(source, /import\s+\{\s*TextToImagePanel\s*\}\s+from\s+'@\/components\/create\/text-to-image'/);
assert.match(source, /import\s+\{\s*ImageToImagePanel\s*\}\s+from\s+'@\/components\/create\/image-to-image'/);
assert.match(source, /import\s+\{\s*TextToVideoPanel\s*\}\s+from\s+'@\/components\/create\/text-to-video'/);
assert.match(source, /import\s+\{\s*ImageToVideoPanel\s*\}\s+from\s+'@\/components\/create\/image-to-video'/);
assert.match(source, /import\s+ReversePromptPanel\s+from\s+'@\/components\/create\/reverse-prompt-panel'/);
assert.doesNotMatch(source, /ssr:\s*false/);
});
await runTest('primary navigation prefetches the core app routes', () => {
await runTest('primary navigation avoids eager all-route prefetch pressure', () => {
const source = read('src/components/navbar.tsx');
assert.match(source, /router\.prefetch\('\/create'\)/);
assert.match(source, /router\.prefetch\('\/gallery'\)/);
assert.match(source, /router\.prefetch\('\/profile'\)/);
assert.match(source, /prefetch=\{true\}/);
assert.doesNotMatch(source, /router\.prefetch\('/);
assert.doesNotMatch(source, /prefetch=\{true\}/);
});
await runTest('non-critical visit tracking waits for browser idle time', () => {

View File

@@ -1,9 +1,13 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useSearchParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TextToImagePanel } from '@/components/create/text-to-image';
import { ImageToImagePanel } from '@/components/create/image-to-image';
import { TextToVideoPanel } from '@/components/create/text-to-video';
import { ImageToVideoPanel } from '@/components/create/image-to-video';
import ReversePromptPanel from '@/components/create/reverse-prompt-panel';
import { Brush, ImagePlus, Video, Film, Loader2, FileSearch } from 'lucide-react';
const TYPE_MAP: Record<string, string> = {
@@ -18,34 +22,6 @@ const DEFAULT_CREATE_TAB = 'text2img';
const CREATE_TAB_STORAGE_KEY = 'miaojing:create-active-tab';
const CREATE_TAB_VALUES = new Set(Object.values(TYPE_MAP));
const CreatePanelFallback = () => (
<div className="flex min-h-[360px] items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-primary" />
</div>
);
const TextToImagePanel = dynamic(
() => import('@/components/create/text-to-image').then(mod => mod.TextToImagePanel),
{ ssr: false, loading: CreatePanelFallback },
);
const ImageToImagePanel = dynamic(
() => import('@/components/create/image-to-image').then(mod => mod.ImageToImagePanel),
{ ssr: false, loading: CreatePanelFallback },
);
const TextToVideoPanel = dynamic(
() => import('@/components/create/text-to-video').then(mod => mod.TextToVideoPanel),
{ ssr: false, loading: CreatePanelFallback },
);
const ImageToVideoPanel = dynamic(
() => import('@/components/create/image-to-video').then(mod => mod.ImageToVideoPanel),
{ ssr: false, loading: CreatePanelFallback },
);
const ReversePromptPanel = dynamic(
() => import('@/components/create/reverse-prompt-panel'),
{ ssr: false, loading: CreatePanelFallback },
);
function normalizeCreateTab(value: string | null): string | null {
if (!value) return null;
return TYPE_MAP[value] || null;

View File

@@ -69,21 +69,6 @@ export function Navbar() {
setMounted(true);
}, []);
useEffect(() => {
const runPrefetch = () => {
router.prefetch('/');
router.prefetch('/create');
router.prefetch('/gallery');
router.prefetch('/profile');
};
const idleCallback = window.requestIdleCallback?.(runPrefetch, { timeout: 1800 });
const timer = idleCallback === undefined ? window.setTimeout(runPrefetch, 900) : null;
return () => {
if (idleCallback !== undefined) window.cancelIdleCallback?.(idleCallback);
if (timer !== null) window.clearTimeout(timer);
};
}, [router]);
// Auth store already refreshes on focus/visibility; keep navbar mount refresh idle and one-shot.
useEffect(() => {
if (!isLoggedIn || profileRefreshStartedRef.current) return;
@@ -161,7 +146,6 @@ export function Navbar() {
<Link
key={item.href}
href={item.href}
prefetch={true}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
isActive
@@ -235,7 +219,6 @@ export function Navbar() {
<Link
key={item.href}
href={item.href}
prefetch={true}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
@@ -300,7 +283,6 @@ export function Navbar() {
<Link
key={item.href}
href={item.href}
prefetch={true}
className={cn(
'flex min-h-12 flex-col items-center justify-center gap-1 rounded-xl text-[11px] font-semibold transition-colors',
isActive ? 'bg-primary/12 text-primary' : 'text-muted-foreground'