Recover from stale Vite chunks (#374)

## Summary
- reload once when a stale Vite dynamic import chunk fails
- suppress recoverable chunk load failures from Sentry
- add Vitest coverage for recovery and Sentry filtering

Fixes PHP-LARAVEL-1P

## Tests
- npm run test -- resources/js/lib/chunk-load-recovery.test.ts
resources/js/lib/sentry.test.ts
- npm run types *(fails: existing TypeScript errors outside this
change)*
This commit is contained in:
Víctor Falcón 2026-05-10 16:20:24 +01:00 committed by GitHub
parent 6335287765
commit 69610c57b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 239 additions and 3 deletions

View File

@ -19,11 +19,17 @@ import { PrivacyModeProvider } from './contexts/privacy-mode-context';
import { SyncProvider } from './contexts/sync-context';
import { initializeTheme } from './hooks/use-appearance';
import { initializeChartColorScheme } from './hooks/use-chart-color-scheme';
import { installChunkLoadRecovery } from './lib/chunk-load-recovery';
import { initializePostHog } from './lib/posthog';
import { isPostMessageDataCloneNoise } from './lib/sentry';
import {
isChunkLoadErrorEvent,
isPostMessageDataCloneNoise,
} from './lib/sentry';
import type { SharedData } from './types';
import { setTranslations } from './utils/i18n';
installChunkLoadRecovery();
Sentry.init({
dsn: import.meta.env.SENTRY_LARAVEL_DSN,
environment: import.meta.env.MODE,
@ -31,7 +37,10 @@ Sentry.init({
tracesSampleRate: 0,
sendDefaultPii: true,
beforeSend(event) {
if (isPostMessageDataCloneNoise(event)) {
if (
isChunkLoadErrorEvent(event) ||
isPostMessageDataCloneNoise(event)
) {
return null;
}

View File

@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import {
isChunkLoadError,
reloadOnChunkLoadError,
} from './chunk-load-recovery';
describe('isChunkLoadError', () => {
it('detects Vite dynamic import fetch failures', () => {
expect(
isChunkLoadError(
new TypeError(
'Failed to fetch dynamically imported module: https://whisper.money/build/assets/accounts-BO3xxENF.js',
),
),
).toBe(true);
});
it('ignores unrelated errors', () => {
expect(isChunkLoadError(new Error('Validation failed'))).toBe(false);
});
});
describe('reloadOnChunkLoadError', () => {
it('reloads once per asset signature', () => {
const reload = vi.fn();
const storage = window.sessionStorage;
storage.clear();
const reason = new TypeError(
'Failed to fetch dynamically imported module: https://whisper.money/build/assets/accounts-BO3xxENF.js',
);
expect(
reloadOnChunkLoadError(reason, {
assetSignature: 'app-old.js',
reload,
storage,
}),
).toBe(true);
expect(
reloadOnChunkLoadError(reason, {
assetSignature: 'app-old.js',
reload,
storage,
}),
).toBe(false);
expect(reload).toHaveBeenCalledOnce();
});
it('does not reload for unrelated errors', () => {
const reload = vi.fn();
expect(
reloadOnChunkLoadError(new Error('Validation failed'), {
assetSignature: 'app-old.js',
reload,
storage: window.sessionStorage,
}),
).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,122 @@
const CHUNK_LOAD_RELOAD_STORAGE_KEY =
'whisper-money:chunk-load-reload-asset-signature';
const CHUNK_LOAD_ERROR_PATTERNS = [
/Failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/Importing a module script failed/i,
/Load failed for module script/i,
/ChunkLoadError/i,
/Loading chunk \d+ failed/i,
];
let inMemoryReloadedAssetSignature: string | null = null;
interface ChunkLoadRecoveryOptions {
assetSignature?: string;
reload?: () => void;
storage?: Storage;
}
export function getCurrentAssetSignature(): string {
const buildAssetScripts = Array.from(document.scripts)
.map((script) => script.src)
.filter((src) => src.includes('/build/assets/'))
.sort();
return buildAssetScripts.join('|') || window.location.href;
}
export function isChunkLoadError(value: unknown): boolean {
const message = getErrorMessage(value);
return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}
export function reloadOnChunkLoadError(
value: unknown,
options: ChunkLoadRecoveryOptions = {},
): boolean {
if (!isChunkLoadError(value)) {
return false;
}
const assetSignature = options.assetSignature ?? getCurrentAssetSignature();
if (hasAlreadyReloadedForAssetSignature(assetSignature, options.storage)) {
return false;
}
markReloadedForAssetSignature(assetSignature, options.storage);
(options.reload ?? (() => window.location.reload()))();
return true;
}
export function installChunkLoadRecovery(): void {
window.addEventListener('unhandledrejection', (event) => {
if (
reloadOnChunkLoadError(event.reason, {
storage: window.sessionStorage,
})
) {
event.preventDefault();
}
});
window.addEventListener('error', (event) => {
if (
reloadOnChunkLoadError(event.error ?? event.message, {
storage: window.sessionStorage,
})
) {
event.preventDefault();
}
});
}
function getErrorMessage(value: unknown): string {
if (value instanceof Error) {
return `${value.name}: ${value.message}`;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'object' && value !== null && 'message' in value) {
return String(value.message);
}
return '';
}
function hasAlreadyReloadedForAssetSignature(
assetSignature: string,
storage?: Storage,
): boolean {
if (inMemoryReloadedAssetSignature === assetSignature) {
return true;
}
try {
return (
storage?.getItem(CHUNK_LOAD_RELOAD_STORAGE_KEY) === assetSignature
);
} catch {
return false;
}
}
function markReloadedForAssetSignature(
assetSignature: string,
storage?: Storage,
): void {
inMemoryReloadedAssetSignature = assetSignature;
try {
storage?.setItem(CHUNK_LOAD_RELOAD_STORAGE_KEY, assetSignature);
} catch {
return;
}
}

View File

@ -1,6 +1,38 @@
import type { Event } from '@sentry/react';
import { describe, expect, it } from 'vitest';
import { isPostMessageDataCloneNoise } from './sentry';
import { isChunkLoadErrorEvent, isPostMessageDataCloneNoise } from './sentry';
describe('isChunkLoadErrorEvent', () => {
it('drops recoverable Vite dynamic import failures', () => {
const event: Event = {
exception: {
values: [
{
type: 'TypeError',
value: 'Failed to fetch dynamically imported module: https://whisper.money/build/assets/accounts-BO3xxENF.js',
},
],
},
};
expect(isChunkLoadErrorEvent(event)).toBe(true);
});
it('keeps unrelated TypeError events', () => {
const event: Event = {
exception: {
values: [
{
type: 'TypeError',
value: 'Cannot read properties of undefined',
},
],
},
};
expect(isChunkLoadErrorEvent(event)).toBe(false);
});
});
describe('isPostMessageDataCloneNoise', () => {
it('drops browser postMessage DataCloneError noise', () => {

View File

@ -1,8 +1,19 @@
import type { Event } from '@sentry/react';
import { isChunkLoadError } from './chunk-load-recovery';
const CLONE_ERROR_MESSAGE_PATTERN =
/object (can not|could not|couldn't|can't) be cloned/i;
export function isChunkLoadErrorEvent(event: Event): boolean {
return (
event.exception?.values?.some((exception) =>
isChunkLoadError(
`${exception.type ?? ''}: ${exception.value ?? ''}`,
),
) ?? false
);
}
export function isPostMessageDataCloneNoise(event: Event): boolean {
return (
event.exception?.values?.some((exception) => {