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
+
应用已更新
+
重新加载后即可继续,书架和阅读进度不受影响。
+
window.location.reload()}>重新加载
+
;
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 },