From 07997b44a534bded1080e560d42baa59f4f26574 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 4 Mar 2026 21:47:31 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20skew=20plugin=20(#12669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: skew plugin Signed-off-by: Innei * refactor(vite): enhance vercelSkewProtection to handle static imports and improve coverage - Added handling for static import/export declarations to ensure correct deployment links. - Updated coverage documentation to reflect new handling for static imports and additional cases. - Adjusted comment numbering for clarity in the processing steps. Signed-off-by: Innei * fix: dev proxy Signed-off-by: Innei * refactor(AssistantGroup): streamline contentId handling in GroupMessage component - Simplified the logic for determining contentId by directly using lastAssistantMsg?.id. - Moved the creation and generation state checks to follow the contentId assignment for better clarity. Signed-off-by: Innei * ♻️ refactor: remove chunk error reload retry, keep notification only Made-with: Cursor * fix: inject Signed-off-by: Innei --------- Signed-off-by: Innei --- plugins/vite/vercelSkewProtection.ts | 160 +++++++++++++++++++++++++ public/_dangerous_local_dev_proxy.html | 99 ++++++++------- src/initialize.ts | 17 +++ src/spa/entry.web.tsx | 9 +- src/utils/chunkError.ts | 31 +++++ src/utils/router.tsx | 66 ++++------ vite.config.ts | 2 + 7 files changed, 297 insertions(+), 87 deletions(-) create mode 100644 plugins/vite/vercelSkewProtection.ts create mode 100644 src/utils/chunkError.ts diff --git a/plugins/vite/vercelSkewProtection.ts b/plugins/vite/vercelSkewProtection.ts new file mode 100644 index 0000000000..807075a092 --- /dev/null +++ b/plugins/vite/vercelSkewProtection.ts @@ -0,0 +1,160 @@ +/** + * Vite plugin: Vercel Skew Protection + * + * Injects ?dpl= into built asset URLs so Vercel Edge + * routes requests to the correct deployment, preventing "Failed to fetch + * dynamically imported module" errors caused by version skew. + * + * Coverage: + * 1. static import/export — renderChunk (post-enforce, after Vite internals) + * 2. dynamic import() — renderChunk + * 3. CSS url() — generateBundle + * 4. HTML diff --git a/src/initialize.ts b/src/initialize.ts index 8c8b0a274f..291f4dc0c9 100644 --- a/src/initialize.ts +++ b/src/initialize.ts @@ -5,6 +5,8 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; import { enableMapSet } from 'immer'; +import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError'; + enableMapSet(); // Dayjs plugins - extend once at app init to avoid duplicate extensions in components @@ -12,3 +14,18 @@ dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(isToday); dayjs.extend(isYesterday); + +// Global fallback: catch async chunk-load failures that escape Error Boundaries +if (typeof window !== 'undefined') { + window.addEventListener('vite:preloadError', (event) => { + event.preventDefault(); + notifyChunkError(); + }); + + window.addEventListener('unhandledrejection', (event) => { + if (isChunkLoadError(event.reason)) { + event.preventDefault(); + notifyChunkError(); + } + }); +} diff --git a/src/spa/entry.web.tsx b/src/spa/entry.web.tsx index 411c0ce308..ae19d731a7 100644 --- a/src/spa/entry.web.tsx +++ b/src/spa/entry.web.tsx @@ -3,6 +3,7 @@ import '../initialize'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import BootErrorBoundary from '@/components/BootErrorBoundary'; import SPAGlobalProvider from '@/layout/SPAGlobalProvider'; import { createAppRouter } from '@/utils/router'; @@ -17,7 +18,9 @@ const basename = const router = createAppRouter(desktopRoutes, { basename }); createRoot(document.getElementById('root')!).render( - - - , + + + + + , ); diff --git a/src/utils/chunkError.ts b/src/utils/chunkError.ts new file mode 100644 index 0000000000..3f3148c635 --- /dev/null +++ b/src/utils/chunkError.ts @@ -0,0 +1,31 @@ +import { toast } from '@lobehub/ui'; + +const CHUNK_ERROR_PATTERNS = [ + 'Failed to fetch dynamically imported module', // Chrome / Vite + 'error loading dynamically imported module', // Firefox + 'Importing a module script failed', // Safari + 'Failed to load module script', // Safari variant + 'Loading chunk', // Webpack + 'Loading CSS chunk', // Webpack CSS + 'ChunkLoadError', // Webpack error name +]; + +/** + * Detect whether an error (or its message) was caused by a failed chunk / dynamic import. + */ +export function isChunkLoadError(error: unknown): boolean { + if (!error) return false; + + const name = (error as Error).name ?? ''; + const message = (error as Error).message ?? String(error); + const combined = `${name} ${message}`; + + return CHUNK_ERROR_PATTERNS.some((p) => combined.includes(p)); +} + +/** + * Show user notification for chunk load error (no reload). + */ +export function notifyChunkError(): void { + toast.info('Web app has been updated so it needs to be reloaded.'); +} diff --git a/src/utils/router.tsx b/src/utils/router.tsx index 8837748462..028afe316b 100644 --- a/src/utils/router.tsx +++ b/src/utils/router.tsx @@ -1,8 +1,7 @@ 'use client'; -import { toast } from '@lobehub/ui'; import { type ComponentType, type ReactElement } from 'react'; -import { createElement, lazy, memo, Suspense, useCallback, useEffect, useRef } from 'react'; +import { createElement, lazy, memo, Suspense, useCallback, useEffect } from 'react'; import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter, @@ -16,6 +15,21 @@ import BusinessGlobalProvider from '@/business/client/BusinessGlobalProvider'; import ErrorCapture from '@/components/Error'; import Loading from '@/components/Loading/BrandTextLoading'; import { useGlobalStore } from '@/store/global'; +import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError'; + +async function importModule(importFn: () => Promise): Promise { + return importFn(); +} + +function resolveLazyModule

(module: { default: ComponentType

} | ComponentType

) { + if (typeof module === 'function') { + return { default: module }; + } + if ('default' in module) { + return module as { default: ComponentType

}; + } + return { default: module as unknown as ComponentType

}; +} /** * Helper function to create a dynamic page element directly for router configuration @@ -34,15 +48,8 @@ export function dynamicElement

>( debugId?: string, ): ReactElement { const LazyComponent = lazy(async () => { - // eslint-disable-next-line @next/next/no-assign-module-variable - const module = await importFn(); - if (typeof module === 'function') { - return { default: module }; - } - if ('default' in module) { - return module as { default: ComponentType

}; - } - return { default: module as unknown as ComponentType

}; + const mod = await importModule(importFn); + return resolveLazyModule(mod); }); // @ts-ignore @@ -63,15 +70,8 @@ export function dynamicLayout

>( debugId?: string, ): ReactElement { const LazyComponent = lazy(async () => { - // eslint-disable-next-line @next/next/no-assign-module-variable - const module = await importFn(); - if (typeof module === 'function') { - return { default: module }; - } - if ('default' in module) { - return module as { default: ComponentType

}; - } - return { default: module as unknown as ComponentType

}; + const mod = await importModule(importFn); + return resolveLazyModule(mod); }); // @ts-ignore @@ -102,35 +102,13 @@ export interface ErrorBoundaryProps { export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => { const error = useRouteError() as Error; - const reloadRef = useRef(false); const navigate = useNavigate(); const reset = useCallback(() => { navigate(resetPath); }, [navigate, resetPath]); - let message = ''; - if (error instanceof Error) { - message = error.message; - } else if (typeof error === 'string') { - message = error; - } else if (error && typeof error === 'object' && 'statusText' in error) { - const statusText = (error as { statusText?: unknown }).statusText; - if (typeof statusText === 'string') message = statusText; - } - - if ( - typeof window !== 'undefined' && - message?.startsWith('Failed to fetch dynamically imported module') && - window.sessionStorage.getItem('reload') !== '1' - ) { - if (reloadRef.current) return null; - - toast.info('Web app has been updated so it needs to be reloaded.'); - window.sessionStorage.setItem('reload', '1'); - window.location.reload(); - reloadRef.current = true; - - return null; + if (typeof window !== 'undefined' && isChunkLoadError(error)) { + notifyChunkError(); } return createElement(ErrorCapture, { error, reset }); diff --git a/vite.config.ts b/vite.config.ts index 6dc3c90430..7b3fc0774d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ import { sharedRendererPlugins, sharedRollupOutput, } from './plugins/vite/sharedRendererConfig'; +import { vercelSkewProtection } from './plugins/vite/vercelSkewProtection'; const isMobile = process.env.MOBILE === 'true'; const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; @@ -32,6 +33,7 @@ export default defineConfig({ define: sharedRendererDefine({ isMobile, isElectron: false }), optimizeDeps: sharedOptimizeDeps, plugins: [ + vercelSkewProtection(), viteEnvRestartKeys(['APP_URL']), ...sharedRendererPlugins({ platform }),