From fbb7298bf0aba4ed64af5ee5b0b3dbbaf8be0556 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Thu, 22 Oct 2026 13:04:18 +0800 Subject: [PATCH] perf(web): split the reader and admin screens out of the first load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/app/router/error-boundary.tsx | 13 +++++++ web/src/app/router/lazy-pages.tsx | 55 +++++++++++++++++++++++++++ web/src/app/router/routes.tsx | 18 +++------ web/tools/ui-review/react-app.mjs | 6 +++ web/vite.config.ts | 22 ++++++----- 5 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 web/src/app/router/lazy-pages.tsx diff --git a/web/src/app/router/error-boundary.tsx b/web/src/app/router/error-boundary.tsx index 9d73734..0aa89d7 100644 --- a/web/src/app/router/error-boundary.tsx +++ b/web/src/app/router/error-boundary.tsx @@ -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
+

应用已更新

+

重新加载后即可继续,书架和阅读进度不受影响。

+ +
; const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : '页面加载失败'; return

页面暂时不可用

diff --git a/web/src/app/router/lazy-pages.tsx b/web/src/app/router/lazy-pages.tsx new file mode 100644 index 0000000..7a1ea62 --- /dev/null +++ b/web/src/app/router/lazy-pages.tsx @@ -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(load: () => Promise, name: K): LazyExoticComponent { + return lazy(() => load().then(module => ({ default: module[name] as ComponentType }))); +} + +const fallback = ; + +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 ; } +export function MediaLibrariesPage() { return ; } +export function MediaLibraryCreatePage() { return ; } +export function MediaLibraryEditPage() { return ; } +export function MediaLibraryPermissionsPage() { return ; } +export function MediaTasksPage() { return ; } +export function MediaMetadataEditPage() { return ; } +export function MediaMatchPage() { return ; } +export function MediaChaptersPage() { return ; } +export function MediaEditionPage() { return ; } +export function MediaStructurePage() { return ; } +export function SourceEditorPage() { return ; } +export function SourceCredentialsPage() { return ; } +export function SourceExtensionPage() { return ; } diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx index 11d773f..9cd68d9 100644 --- a/web/src/app/router/routes.tsx +++ b/web/src/app/router/routes.tsx @@ -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: , errorElement: , children: [ diff --git a/web/tools/ui-review/react-app.mjs b/web/tools/ui-review/react-app.mjs index 3938213..76ae81e 100644 --- a/web/tools/ui-review/react-app.mjs +++ b/web/tools/ui-review/react-app.mjs @@ -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`); diff --git a/web/vite.config.ts b/web/vite.config.ts index ca3f323..c1acd14 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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 },