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:
parent
6335287765
commit
69610c57b3
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue