Merge branch 'master' into codex/zongmeng-v1
This commit is contained in:
commit
7e1e0bf7a2
9 files changed
+452
-27
No files matched your search
@@ -0,0 +1,2 @@
|
||||
/version.json
|
||||
Cache-Control: no-store
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+2
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_BUILD_ID__: string;
|
||||
+21
-4
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user