Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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))
Expand Down
14 changes: 14 additions & 0 deletions src/services/staleAssetErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion tests/stale-asset-errors.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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')
Expand Down
Loading