perf(web): split the reader and admin screens out of the first load

client.js was one 1.48 MB bundle (413 kB gzip) because the build assumed the
Android shell loads from file://. It no longer does: WebHost serves the
bundle from https://appassets.androidplatform.net/ via WebViewAssetLoader,
the service worker precaches every emitted file, and the server serves any
file under assets/ (caching hashed names forever).

- The reader, media library/task/item admin pages and source editor pages
  load on first visit (app/router/lazy-pages.tsx); wrappers keep the page
  names, so the route tree and its tests read unchanged.
- Chunks are content-hashed; the entry stays client.js and CSS stays one
  index.css, so no host-facing name changes and no unstyled flash.
- A tab running an old client after a deploy gets "应用已更新 · 重新加载"
  instead of a dynamic-import error.
- ui:review now opens a book and asserts the reader chunk loads and renders.

client.js: 1478 kB -> 777 kB (gzip 413 -> 231 kB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-22 13:04:18 +08:00
1 parent 597369392e
commit fbb7298bf0
5 files changed
+92 -22

No files matched your search

+13
View File
@@ -1,9 +1,22 @@
import { isRouteErrorResponse, useRouteError, useNavigate } from 'react-router-dom';
import { Button } from '../../ui/toolkit.tsx';
/**
* A lazily loaded screen (lazy-pages.tsx) whose chunk is gone: the server was updated
* while this tab still ran the old client, which asks for the old chunk hashes. The
* fix is a reload, so the screen says that instead of echoing the browser's message.
*/
const staleChunk = (error: unknown) => error instanceof Error
&& /dynamically imported module|Importing a module script failed|error loading dynamically imported/i.test(error.message);
export function RouteErrorBoundary() {
const error = useRouteError();
const navigate = useNavigate();
if (staleChunk(error)) return <div className="page-state" role="alert">
<h2>应用已更新</h2>
<p>重新加载后即可继续,书架和阅读进度不受影响。</p>
<Button className="primary" onClick={() => window.location.reload()}>重新加载</Button>
</div>;
const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : '页面加载失败';
return <div className="page-state" role="alert">
<h2>页面暂时不可用</h2>
+55
View File
@@ -0,0 +1,55 @@
import { lazy, Suspense, type ComponentType, type LazyExoticComponent } from 'react';
import { FloatingNotice } from '../../ui/floating-notice.tsx';
/**
* Screens that most sessions never open, loaded on first visit.
*
* The reader (EPUB/PDF/ZIP parsing, pagination, TTS) and the administrator screens
* were half of a 1.5 MB first load for someone who only opens the shelf. Each
* wrapper keeps the page's own name, so the route tree still reads — and is tested
* — as `ReaderPage`, `MediaLibrariesPage`, … rather than as anonymous lazy objects.
*/
function deferred<M, K extends keyof M>(load: () => Promise<M>, name: K): LazyExoticComponent<ComponentType> {
return lazy(() => load().then(module => ({ default: module[name] as ComponentType })));
}
const fallback = <FloatingNotice message="正在加载…" busy />;
const reader = () => import('../../features/reader/pages/ReaderPage.tsx');
const libraries = () => import('../../features/media/pages/MediaLibrariesPage.tsx');
const libraryEditors = () => import('../../features/media/pages/MediaLibraryEditorPages.tsx');
const tasks = () => import('../../features/media/pages/MediaTasksPage.tsx');
const itemManagement = () => import('../../features/media/pages/MediaItemManagementPages.tsx');
const sourceEditor = () => import('../../features/sources/pages/SourceEditorPage.tsx');
const sourceCredentials = () => import('../../features/sources/pages/SourceCredentialsPage.tsx');
const sourceExtension = () => import('../../features/sources/pages/SourceExtensionPage.tsx');
const Reader = deferred(reader, 'ReaderPage');
const Libraries = deferred(libraries, 'MediaLibrariesPage');
const LibraryCreate = deferred(libraries, 'MediaLibraryCreatePage');
const LibraryEdit = deferred(libraryEditors, 'MediaLibraryEditPage');
const LibraryPermissions = deferred(libraryEditors, 'MediaLibraryPermissionsPage');
const Tasks = deferred(tasks, 'MediaTasksPage');
const MetadataEdit = deferred(itemManagement, 'MediaMetadataEditPage');
const Match = deferred(itemManagement, 'MediaMatchPage');
const Chapters = deferred(itemManagement, 'MediaChaptersPage');
const Edition = deferred(itemManagement, 'MediaEditionPage');
const Structure = deferred(itemManagement, 'MediaStructurePage');
const SourceEditor = deferred(sourceEditor, 'SourceEditorPage');
const SourceCredentials = deferred(sourceCredentials, 'SourceCredentialsPage');
const SourceExtension = deferred(sourceExtension, 'SourceExtensionPage');
export function ReaderPage() { return <Suspense fallback={fallback}><Reader /></Suspense>; }
export function MediaLibrariesPage() { return <Suspense fallback={fallback}><Libraries /></Suspense>; }
export function MediaLibraryCreatePage() { return <Suspense fallback={fallback}><LibraryCreate /></Suspense>; }
export function MediaLibraryEditPage() { return <Suspense fallback={fallback}><LibraryEdit /></Suspense>; }
export function MediaLibraryPermissionsPage() { return <Suspense fallback={fallback}><LibraryPermissions /></Suspense>; }
export function MediaTasksPage() { return <Suspense fallback={fallback}><Tasks /></Suspense>; }
export function MediaMetadataEditPage() { return <Suspense fallback={fallback}><MetadataEdit /></Suspense>; }
export function MediaMatchPage() { return <Suspense fallback={fallback}><Match /></Suspense>; }
export function MediaChaptersPage() { return <Suspense fallback={fallback}><Chapters /></Suspense>; }
export function MediaEditionPage() { return <Suspense fallback={fallback}><Edition /></Suspense>; }
export function MediaStructurePage() { return <Suspense fallback={fallback}><Structure /></Suspense>; }
export function SourceEditorPage() { return <Suspense fallback={fallback}><SourceEditor /></Suspense>; }
export function SourceCredentialsPage() { return <Suspense fallback={fallback}><SourceCredentials /></Suspense>; }
export function SourceExtensionPage() { return <Suspense fallback={fallback}><SourceExtension /></Suspense>; }
+5 -13
View File
@@ -12,26 +12,18 @@ import { RouteErrorBoundary } from './error-boundary.tsx';
import { ShelfPage } from '../../features/shelf/pages/ShelfPage.tsx';
import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features/library/pages/LibraryPage.tsx';
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
import { MinePage } from '../../features/account/pages/MinePage.tsx';
import { HomePage } from '../../features/home/pages/HomePage.tsx';
import { SearchPage } from '../../features/search/pages/SearchPage.tsx';
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
import { MediaTasksPage } from '../../features/media/pages/MediaTasksPage.tsx';
import { MediaFoldersPage } from '../../features/media/pages/MediaFoldersPage.tsx';
import { MediaNarratorsPage } from '../../features/media/pages/MediaNarratorsPage.tsx';
import {
MediaMetadataEditPage,
MediaMatchPage,
MediaChaptersPage,
MediaEditionPage,
MediaStructurePage,
} from '../../features/media/pages/MediaItemManagementPages.tsx';
ReaderPage,
MediaLibrariesPage, MediaLibraryCreatePage, MediaLibraryEditPage, MediaLibraryPermissionsPage, MediaTasksPage,
MediaMetadataEditPage, MediaMatchPage, MediaChaptersPage, MediaEditionPage, MediaStructurePage,
SourceEditorPage, SourceCredentialsPage, SourceExtensionPage,
} from './lazy-pages.tsx';
import { SourceCatalogPage } from '../../features/sources/pages/SourceCatalogPage.tsx';
import { SourceEditorPage } from '../../features/sources/pages/SourceEditorPage.tsx';
import { SourceCredentialsPage } from '../../features/sources/pages/SourceCredentialsPage.tsx';
import { SourceExtensionPage } from '../../features/sources/pages/SourceExtensionPage.tsx';
export const routes = [{
path: '/', element: <AppShell />, errorElement: <RouteErrorBoundary />, children: [
+6
View File
@@ -215,6 +215,12 @@ async function runReaderReview(browser, origin, checks, errors) {
await page.goto(`${origin}/#/sources`);
await waitHeading(page, '书源');
await capture(page, 'sources', checks, errors);
// The reader is a lazily loaded chunk (app/router/lazy-pages.tsx): opening a book
// must fetch it and render the chapter, not stop at the Suspense fallback.
const readerChunk = page.waitForResponse(response => /\/assets\/ReaderPage-[\w-]+\.js$/.test(response.url()));
await page.goto(`${origin}/#/book/review-book`);
assert.equal((await readerChunk).status(), 200);
await page.locator('book-content').getByText('第 1 段', { exact: false }).first().waitFor();
// Hash history is part of the router contract.
await page.goto(`${origin}/#/shelf`);
await page.goto(`${origin}/#/library`);
+13 -9
View File
@@ -2,14 +2,17 @@ import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { pwa } from './tools/pwa/build.ts';
// The build output is consumed twice:
// The build output is consumed three ways:
// - served directly by the server for the browser/H5 build
// - copied into the Android app's assets and loaded from file:// in a WebView
// - precached by the service worker (tools/pwa/build.ts lists every emitted file)
// - copied into the Android app's assets and served by WebViewAssetLoader from
// https://appassets.androidplatform.net/ (see WebHost.kt), not from file://
//
// Because of the second consumer the build is a single self-contained bundle
// with relative asset URLs (base: './') and no code-splitting: a WebView loading
// from file:// cannot fetch sibling chunks over the network, and every extra
// request through the file protocol is a chance to meet a platform quirk.
// So relative asset URLs (base: './'), a fixed entry name (client.js) and one
// stylesheet (index.css, so a lazily loaded screen never paints unstyled), but
// rarely used screens (the reader, admin pages; app/router/lazy-pages.tsx) are
// split into content-hashed chunks: every host fetches them like any sibling file,
// and the server caches the hashed names forever.
export default defineConfig({
plugins: [react(), pwa()],
base: './',
@@ -18,12 +21,13 @@ export default defineConfig({
emptyOutDir: true,
target: 'es2020',
assetsInlineLimit: 0,
cssCodeSplit: false,
rollupOptions: {
output: {
inlineDynamicImports: true,
entryFileNames: 'assets/client.js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name][extname]',
chunkFileNames: 'assets/[name]-[hash].js',
// With cssCodeSplit off Vite names the one stylesheet style.css; keep index.css.
assetFileNames: asset => asset.names?.[0] === 'style.css' ? 'assets/index.css' : 'assets/[name][extname]',
},
},
modulePreload: { polyfill: false },