Merge branch 'master' into codex/zongmeng-v1

This commit is contained in:
taojiacheng committed 2026-07-16 12:30:25 +08:00
commit 7e1e0bf7a2
9 files changed
+452 -27

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/version.json
Cache-Control: no-store
+21
View File
@@ -0,0 +1,21 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { RootRouteErrorView } from './App';
describe('RootRouteErrorView', () => {
it('renders a friendly recovery action without exposing a stale chunk URL', () => {
const html = renderToStaticMarkup(
<RootRouteErrorView
error={
new Error(
'Failed to fetch dynamically imported module: /assets/route-old.js',
)
}
/>,
);
expect(html).toContain('版本已更迭');
expect(html).toContain('刷新进入新版本');
expect(html).not.toContain('route-old.js');
});
});
+58 -21
View File
@@ -1,6 +1,11 @@
import { AppVersionNotifier } from '@app/components/feature/app-version/AppVersionNotifier';
import { InkUIProvider } from '@app/components/providers/InkUIProvider';
import Link from '@app/components/router/AppLink';
import { RouteDocumentTitle } from '@app/components/router/RouteDocumentTitle';
import {
isDynamicImportError,
reloadIntoLatestVersion,
} from '@app/lib/appVersion';
import { APP_TITLE, formatDocumentTitle } from '@app/lib/router/routeTitle';
import {
Outlet,
@@ -27,6 +32,7 @@ function NavigationIndicator() {
export default function App() {
return (
<InkUIProvider>
<AppVersionNotifier />
<RouteDocumentTitle />
<ScrollRestoration />
<NavigationIndicator />
@@ -38,36 +44,67 @@ export default function App() {
export function RootRouteErrorBoundary() {
const error = useRouteError();
const message = isRouteErrorResponse(error)
? typeof error.data === 'string'
? error.data
: error.statusText || '页面加载失败'
: error instanceof Error
? error.message
: '页面加载失败';
return <RootRouteErrorView error={error} />;
}
export function RootRouteErrorView({ error }: { error: unknown }) {
const isVersionError = isDynamicImportError(error);
const message = isVersionError
? '你停留的版本已被新的天地法则替代,刷新后即可继续当前旅程。'
: isRouteErrorResponse(error)
? typeof error.data === 'string'
? error.data
: error.statusText || '页面加载失败'
: error instanceof Error
? error.message
: '页面加载失败';
const pageTitle = isVersionError ? '版本已更迭' : '道途异常';
return (
<div className="bg-paper flex min-h-screen items-center justify-center px-6">
<title>{formatDocumentTitle('道途异常')}</title>
<title>{formatDocumentTitle(pageTitle)}</title>
<div className="w-full max-w-xl p-6">
<p className="text-ink-secondary text-xs tracking-[0.2em]">
{APP_TITLE}
</p>
<h1 className="text-ink mt-3 text-2xl font-semibold">道途出现偏差</h1>
<h1 className="text-ink mt-3 text-2xl font-semibold">
{isVersionError ? '版本已更迭' : '道途出现偏差'}
</h1>
<p className="text-ink-secondary mt-3 text-sm leading-7">{message}</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/"
className="border-ink/20 text-ink hover:border-crimson/40 hover:text-crimson border border-dashed px-3 py-2 no-underline"
>
返回首页
</Link>
<Link
href="/game"
className="border-ink/20 text-ink hover:border-crimson/40 hover:text-crimson border border-dashed px-3 py-2 no-underline"
>
返回游戏
</Link>
{isVersionError ? (
<>
<button
type="button"
onClick={reloadIntoLatestVersion}
className="border-crimson/40 text-crimson hover:bg-crimson/5 cursor-pointer border px-3 py-2"
>
刷新进入新版本
</button>
<a
href="/"
className="border-ink/20 text-ink hover:border-crimson/40 hover:text-crimson border border-dashed px-3 py-2 no-underline"
>
返回首页
</a>
</>
) : (
<>
<Link
href="/"
className="border-ink/20 text-ink hover:border-crimson/40 hover:text-crimson border border-dashed px-3 py-2 no-underline"
>
返回首页
</Link>
<Link
href="/game"
className="border-ink/20 text-ink hover:border-crimson/40 hover:text-crimson border border-dashed px-3 py-2 no-underline"
>
返回游戏
</Link>
</>
)}
</div>
</div>
</div>
@@ -0,0 +1,79 @@
import { useInkUI } from '@app/components/providers/InkUIProvider';
import {
CURRENT_BUILD_ID,
fetchLatestBuildId,
isNewBuildAvailable,
reloadIntoLatestVersion,
} from '@app/lib/appVersion';
import { useEffect, useRef } from 'react';
const VERSION_CHECK_INTERVAL_MS = 5 * 60 * 1000;
export function AppVersionNotifier() {
const { dismissToast, pushToast } = useInkUI();
const checkInFlightRef = useRef(false);
const promptedBuildIdRef = useRef<string | null>(null);
const promptToastIdRef = useRef<string | null>(null);
useEffect(() => {
if (!import.meta.env.PROD) {
return;
}
let active = true;
const checkForUpdate = async () => {
if (document.visibilityState === 'hidden' || checkInFlightRef.current) {
return;
}
checkInFlightRef.current = true;
try {
const latestBuildId = await fetchLatestBuildId();
if (
!active ||
!isNewBuildAvailable(latestBuildId, CURRENT_BUILD_ID) ||
promptedBuildIdRef.current === latestBuildId
) {
return;
}
if (promptToastIdRef.current) {
dismissToast(promptToastIdRef.current);
}
promptedBuildIdRef.current = latestBuildId;
promptToastIdRef.current = pushToast({
message: '天地法则已有更新,刷新后即可继续当前旅程。',
tone: 'warning',
duration: 0,
actionLabel: '刷新进入新版本',
onAction: reloadIntoLatestVersion,
});
} finally {
checkInFlightRef.current = false;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void checkForUpdate();
}
};
void checkForUpdate();
const intervalId = window.setInterval(
() => void checkForUpdate(),
VERSION_CHECK_INTERVAL_MS,
);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
active = false;
window.clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [dismissToast, pushToast]);
return null;
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest';
import {
fetchLatestBuildId,
isDynamicImportError,
isNewBuildAvailable,
parseAppVersionManifest,
recoverFromPreloadError,
} from './appVersion';
function createStorage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
}
describe('app version manifest', () => {
it('accepts a non-empty build id', () => {
expect(parseAppVersionManifest({ buildId: 'build-b' })).toEqual({
buildId: 'build-b',
});
});
it('rejects malformed manifests', () => {
expect(parseAppVersionManifest(null)).toBeNull();
expect(parseAppVersionManifest({})).toBeNull();
expect(parseAppVersionManifest({ buildId: ' ' })).toBeNull();
});
it('fetches the latest build without using the browser cache', async () => {
const request = vi.fn(async () => Response.json({ buildId: 'build-b' }));
await expect(fetchLatestBuildId(request)).resolves.toBe('build-b');
expect(request).toHaveBeenCalledWith('/version.json', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
});
it('silently ignores failed and malformed version responses', async () => {
const failedRequest = vi.fn(
async () => new Response(null, { status: 503 }),
);
const malformedRequest = vi.fn(async () => Response.json({ version: 1 }));
await expect(fetchLatestBuildId(failedRequest)).resolves.toBeNull();
await expect(fetchLatestBuildId(malformedRequest)).resolves.toBeNull();
});
it('only reports a different non-empty build as available', () => {
expect(isNewBuildAvailable('build-b', 'build-a')).toBe(true);
expect(isNewBuildAvailable('build-a', 'build-a')).toBe(false);
expect(isNewBuildAvailable(null, 'build-a')).toBe(false);
});
});
describe('preload error recovery', () => {
it('reloads once for the current build', () => {
const storage = createStorage();
const preventDefault = vi.fn();
const reload = vi.fn();
expect(
recoverFromPreloadError(
{ preventDefault },
{ buildId: 'build-a', storage, reload },
),
).toBe(true);
expect(preventDefault).toHaveBeenCalledOnce();
expect(reload).toHaveBeenCalledOnce();
expect(
recoverFromPreloadError(
{ preventDefault },
{ buildId: 'build-a', storage, reload },
),
).toBe(false);
expect(preventDefault).toHaveBeenCalledOnce();
expect(reload).toHaveBeenCalledOnce();
});
it('allows a later build to recover once again', () => {
const storage = createStorage();
const preventDefault = vi.fn();
const reload = vi.fn();
recoverFromPreloadError(
{ preventDefault },
{ buildId: 'build-a', storage, reload },
);
recoverFromPreloadError(
{ preventDefault },
{ buildId: 'build-b', storage, reload },
);
expect(preventDefault).toHaveBeenCalledTimes(2);
expect(reload).toHaveBeenCalledTimes(2);
});
it('falls back to the error boundary when session storage is unavailable', () => {
const preventDefault = vi.fn();
const reload = vi.fn();
expect(
recoverFromPreloadError(
{ preventDefault },
{
buildId: 'build-a',
storage: {
getItem: () => {
throw new Error('storage blocked');
},
setItem: vi.fn(),
},
reload,
},
),
).toBe(false);
expect(preventDefault).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
});
describe('dynamic import errors', () => {
it.each([
'Failed to fetch dynamically imported module: /assets/route-old.js',
'ChunkLoadError: Loading chunk 42 failed',
'Importing a module script failed',
'Unable to preload CSS for /assets/route-old.css',
])('recognizes %s', (message) => {
expect(isDynamicImportError(new Error(message))).toBe(true);
});
it('does not classify unrelated route errors as version drift', () => {
expect(isDynamicImportError(new Error('Permission denied'))).toBe(false);
});
});
+123
View File
@@ -0,0 +1,123 @@
export interface AppVersionManifest {
buildId: string;
}
export interface PreloadRecoveryStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}
export interface PreloadRecoveryEvent {
preventDefault: () => void;
}
export interface PreloadRecoveryDependencies {
buildId: string;
storage: PreloadRecoveryStorage;
reload: () => void;
}
const PRELOAD_RECOVERY_BUILD_KEY = 'daoyou:preload-recovery-build';
const DYNAMIC_IMPORT_ERROR_PATTERNS = [
/failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/importing a module script failed/i,
/chunkloaderror/i,
/loading chunk .+ failed/i,
/unable to preload css/i,
];
export const CURRENT_BUILD_ID =
typeof __APP_BUILD_ID__ === 'string' ? __APP_BUILD_ID__ : 'test';
export function parseAppVersionManifest(
value: unknown,
): AppVersionManifest | null {
if (!value || typeof value !== 'object' || !('buildId' in value)) {
return null;
}
const buildId = value.buildId;
if (typeof buildId !== 'string' || buildId.trim().length === 0) {
return null;
}
return { buildId };
}
export async function fetchLatestBuildId(
request: typeof fetch = fetch,
): Promise<string | null> {
try {
const response = await request('/version.json', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
const manifest = parseAppVersionManifest(await response.json());
return manifest?.buildId ?? null;
} catch {
return null;
}
}
export function isNewBuildAvailable(
latestBuildId: string | null,
currentBuildId = CURRENT_BUILD_ID,
): latestBuildId is string {
return latestBuildId !== null && latestBuildId !== currentBuildId;
}
export function isDynamicImportError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: '';
return DYNAMIC_IMPORT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}
export function reloadIntoLatestVersion() {
window.location.reload();
}
export function recoverFromPreloadError(
event: PreloadRecoveryEvent,
dependencies: PreloadRecoveryDependencies,
): boolean {
const { buildId, storage, reload } = dependencies;
try {
if (storage.getItem(PRELOAD_RECOVERY_BUILD_KEY) === buildId) {
return false;
}
storage.setItem(PRELOAD_RECOVERY_BUILD_KEY, buildId);
} catch {
return false;
}
event.preventDefault();
reload();
return true;
}
export function registerPreloadErrorRecovery() {
window.addEventListener('vite:preloadError', (event) => {
try {
recoverFromPreloadError(event, {
buildId: CURRENT_BUILD_ID,
storage: window.sessionStorage,
reload: reloadIntoLatestVersion,
});
} catch {
// Let the route error boundary handle the failure when storage is blocked.
}
});
}
+7 -2
View File
@@ -1,9 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { resolveApiUrl } from './lib/api/url';
import { router } from './router';
import './index.css';
import { resolveApiUrl } from './lib/api/url';
import { registerPreloadErrorRecovery } from './lib/appVersion';
import { router } from './router';
if (import.meta.env.PROD) {
registerPreloadErrorRecovery();
}
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_BUILD_ID__: string;
+21 -4
View File
@@ -1,16 +1,16 @@
import build from '@hono/vite-build/bun';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import { defineConfig, loadEnv, type Plugin } from 'vite';
const alias = {
'@app': fileURLToPath(new URL('./src/react-app', import.meta.url)),
'@server': fileURLToPath(new URL('./src/server', import.meta.url)),
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url)),
};
const devApiTarget = () =>
`http://localhost:${process.env.API_PORT ?? 3000}`;
const devApiTarget = () => `http://localhost:${process.env.API_PORT ?? 3000}`;
const applyEnvToProcess = (mode: string) => {
const env = loadEnv(mode, process.cwd(), '');
@@ -22,13 +22,29 @@ const applyEnvToProcess = (mode: string) => {
}
};
const createBuildId = () =>
process.env.CF_PAGES_COMMIT_SHA ?? process.env.GITHUB_SHA ?? randomUUID();
const appVersionManifestPlugin = (buildId: string): Plugin => ({
name: 'app-version-manifest',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'version.json',
source: `${JSON.stringify({ buildId })}\n`,
});
},
});
export default defineConfig(({ command, mode }) => {
applyEnvToProcess(mode);
const buildId = createBuildId();
if (mode === 'client') {
return {
resolve: { alias },
plugins: [react(), tailwindcss()],
define: { __APP_BUILD_ID__: JSON.stringify(buildId) },
plugins: [react(), tailwindcss(), appVersionManifestPlugin(buildId)],
build: {
outDir: 'dist',
emptyOutDir: true,
@@ -50,6 +66,7 @@ export default defineConfig(({ command, mode }) => {
return {
resolve: { alias },
define: { __APP_BUILD_ID__: JSON.stringify(buildId) },
plugins: [react(), tailwindcss()],
server: {
proxy: {