diff --git a/src/main.ts b/src/main.ts index 8515c92ecd..67398dd010 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,7 @@ import { routes } from 'vue-router/auto-routes' import { installDeepLinkHandler } from '~/services/deepLinks' import { getNativeExternalPurchaseRedirect, isNativeAppStoreContext, isNativeExternalPurchaseRestrictedPath } from '~/services/nativeCompliance' import { posthogLoader } from '~/services/posthog' -import { getErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage } from '~/services/staleAssetErrors' +import { getErrorMessage, isComponentResolutionErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage } from '~/services/staleAssetErrors' import { getLocalConfig } from '~/services/supabase' import App from './App.vue' import { getRemoteConfig } from './services/supabase' @@ -121,8 +121,13 @@ window.addEventListener('vite:preloadError', (event) => { ?? 'Vite preload error' if (!isStaleAssetErrorMessage(message)) return - event.preventDefault() - event.stopImmediatePropagation() + // Deliberately do NOT call event.preventDefault(): Vite's preload helper only + // rethrows the underlying import failure when the default is not prevented. + // Preventing it makes `__vitePreload` resolve with `undefined`, so a lazy route + // resolves to a falsy component and vue-router throws "Couldn't resolve + // component" instead of the real chunk error. Letting it rethrow keeps the + // rejection matching STALE_ASSET_ERROR_PATTERNS, so it still gets the reload + // (below) and the PostHog suppression this handler was written to provide. handleChunkError(message) }) @@ -230,6 +235,18 @@ const router = createRouter({ ], history: createWebHistory(import.meta.env.BASE_URL), }) + +// Recover from lazy-route load failures that slip past the vite:preloadError +// handler (e.g. a navigation that races the reload). vue-router surfaces these +// as "Couldn't resolve component" once a stale chunk fails to import, so treat +// them as chunk errors and trigger the same reload instead of leaving the user +// on a dead route. +router.onError((error) => { + const message = getErrorMessage(error) ?? String(error) + if (isStaleAssetErrorMessage(message) || isComponentResolutionErrorMessage(message)) + handleChunkError(message) +}) + router.beforeEach((to, from, next) => { if (isNativeAppStoreContext() && isNativeExternalPurchaseRestrictedPath(to.path)) { return next(getNativeExternalPurchaseRedirect(to.path)) diff --git a/src/services/staleAssetErrors.ts b/src/services/staleAssetErrors.ts index d54803c172..6e3271fc17 100644 --- a/src/services/staleAssetErrors.ts +++ b/src/services/staleAssetErrors.ts @@ -16,6 +16,13 @@ const KNOWN_CRAWLER_ERROR_PATTERNS = [ /Object Not Found Matching Id:\d+(?:,\s*MethodName:[^,]+,\s*ParamCount:\d+)?/i, ] +// vue-router throws this when a lazy route component fails to load (e.g. a stale +// chunk 404 during a deploy). It can also surface if a navigation races the +// automatic reload we trigger for stale chunks, so we treat it as a chunk error. +const COMPONENT_RESOLUTION_ERROR_PATTERNS = [ + /Couldn't resolve component/i, +] + export function isStaleAssetErrorMessage(message: string | undefined): boolean { if (!message) return false @@ -30,6 +37,13 @@ export function isKnownCrawlerNoiseErrorMessage(message: string | undefined): bo return KNOWN_CRAWLER_ERROR_PATTERNS.some(pattern => pattern.test(message)) } +export function isComponentResolutionErrorMessage(message: string | undefined): boolean { + if (!message) + return false + + return COMPONENT_RESOLUTION_ERROR_PATTERNS.some(pattern => pattern.test(message)) +} + interface PostHogExceptionLike { value?: unknown $exception_value?: unknown diff --git a/tests/stale-asset-errors.unit.test.ts b/tests/stale-asset-errors.unit.test.ts index 441595f1ae..2cf3b269e5 100644 --- a/tests/stale-asset-errors.unit.test.ts +++ b/tests/stale-asset-errors.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage, shouldSuppressPostHogExceptionEvent } from '../src/services/staleAssetErrors' +import { getErrorMessage, isComponentResolutionErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage, shouldSuppressPostHogExceptionEvent } from '../src/services/staleAssetErrors' describe('stale asset error helpers', () => { it('matches the stale asset errors currently seen in PostHog', () => { @@ -25,6 +25,13 @@ describe('stale asset error helpers', () => { expect(isKnownCrawlerNoiseErrorMessage('Cannot read properties of null (reading \'save\')')).toBe(false) }) + it('matches the vue-router component-resolution error caused by stale chunks', () => { + expect(isComponentResolutionErrorMessage('Couldn\'t resolve component "default" at "/app/:app/device/:device"')).toBe(true) + expect(isComponentResolutionErrorMessage(new Error('Couldn\'t resolve component "default" at "/app/:app"').message)).toBe(true) + expect(isComponentResolutionErrorMessage('Navigation cancelled from "/" to "/apps" with a new navigation.')).toBe(false) + expect(isComponentResolutionErrorMessage(undefined)).toBe(false) + }) + it('extracts useful messages from arbitrary rejection values', () => { expect(getErrorMessage(new Error('Importing a module script failed.'))).toBe('Importing a module script failed.') expect(getErrorMessage({ message: 'Unable to preload CSS for /assets/main.css' })).toBe('Unable to preload CSS for /assets/main.css')