diff --git a/packages/frontend/editor-ui/src/app/plugins/sentry.test.ts b/packages/frontend/editor-ui/src/app/plugins/sentry.test.ts index 06dbddef26d..f2b389b70f5 100644 --- a/packages/frontend/editor-ui/src/app/plugins/sentry.test.ts +++ b/packages/frontend/editor-ui/src/app/plugins/sentry.test.ts @@ -38,6 +38,16 @@ describe('beforeSend', () => { expect(beforeSend(event, hint)).toBeNull(); }); + it.each([ + 'Failed to fetch dynamically imported module: https://example.com/assets/RunDataSearch-abc.js', + 'error loading dynamically imported module: https://example.com/assets/RunDataSearch-abc.js', + 'Importing a module script failed.', + ])('should return null for stale-chunk preload TypeError: %s', (message) => { + const event = createErrorEvent(); + const hint = { originalException: new TypeError(message) }; + expect(beforeSend(event, hint)).toBeNull(); + }); + it('should return event when originalException does not match any ignoredErrors', () => { const event = createErrorEvent(); const hint = { originalException: new Error('Some other error') }; diff --git a/packages/frontend/editor-ui/src/app/plugins/sentry.ts b/packages/frontend/editor-ui/src/app/plugins/sentry.ts index 0d9ff75a351..4a3790a79ef 100644 --- a/packages/frontend/editor-ui/src/app/plugins/sentry.ts +++ b/packages/frontend/editor-ui/src/app/plugins/sentry.ts @@ -14,6 +14,12 @@ const ignoredErrors = [ { instanceof: RangeError, message: /Invalid change range \d+ to \d+/ }, { instanceof: RangeError, message: /Selection points outside of document$/ }, { instanceof: Error, message: /ResizeObserver/ }, + // Stale-chunk preload errors after a deploy — the page auto-reloads to recover. + // Known blind spot: a broken deploy (chunk missing for everyone) produces the same + // message and is also suppressed; rely on CI / smoke checks for that signal. + { instanceof: TypeError, message: /Failed to fetch dynamically imported module/ }, + { instanceof: TypeError, message: /error loading dynamically imported module/ }, + { instanceof: TypeError, message: /Importing a module script failed/ }, ] as const; type SentryConfig = { diff --git a/packages/frontend/editor-ui/src/app/plugins/vitePreloadError.test.ts b/packages/frontend/editor-ui/src/app/plugins/vitePreloadError.test.ts new file mode 100644 index 00000000000..a51eac37c92 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/plugins/vitePreloadError.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerVitePreloadErrorHandler } from '@/app/plugins/vitePreloadError'; + +const STORAGE_KEY = 'n8n:vite-preload-reloaded-at'; + +function dispatchPreloadError() { + const event = new Event('vite:preloadError', { cancelable: true }); + window.dispatchEvent(event); + return event; +} + +describe('registerVitePreloadErrorHandler', () => { + let reloadSpy: ReturnType; + let originalLocation: Location; + let unregister: () => void; + + beforeEach(() => { + sessionStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + originalLocation = window.location; + reloadSpy = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, reload: reloadSpy }, + }); + + unregister = registerVitePreloadErrorHandler(); + }); + + afterEach(() => { + unregister(); + vi.useRealTimers(); + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + sessionStorage.clear(); + }); + + it('reloads and stores a timestamp on the first preload error', () => { + const event = dispatchPreloadError(); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + expect(Number(sessionStorage.getItem(STORAGE_KEY))).toBe(Date.now()); + }); + + it('does not reload on a second preload error within the throttle window', () => { + dispatchPreloadError(); + vi.advanceTimersByTime(5_000); + const event = dispatchPreloadError(); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(false); + }); + + it('reloads again after the throttle window has elapsed', () => { + dispatchPreloadError(); + vi.advanceTimersByTime(10_001); + dispatchPreloadError(); + + expect(reloadSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/plugins/vitePreloadError.ts b/packages/frontend/editor-ui/src/app/plugins/vitePreloadError.ts new file mode 100644 index 00000000000..bb5ddbd0c51 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/plugins/vitePreloadError.ts @@ -0,0 +1,15 @@ +const VITE_PRELOAD_RELOAD_TS = 'n8n:vite-preload-reloaded-at'; +const RELOAD_THROTTLE_MS = 10_000; + +export function registerVitePreloadErrorHandler(): () => void { + const handler = (event: Event) => { + const last = Number(sessionStorage.getItem(VITE_PRELOAD_RELOAD_TS) ?? 0); + if (Date.now() - last < RELOAD_THROTTLE_MS) return; + sessionStorage.setItem(VITE_PRELOAD_RELOAD_TS, String(Date.now())); + event.preventDefault(); + window.location.reload(); + }; + + window.addEventListener('vite:preloadError', handler); + return () => window.removeEventListener('vite:preloadError', handler); +} diff --git a/packages/frontend/editor-ui/src/main.ts b/packages/frontend/editor-ui/src/main.ts index 8826d01ffcf..71778936048 100644 --- a/packages/frontend/editor-ui/src/main.ts +++ b/packages/frontend/editor-ui/src/main.ts @@ -27,11 +27,14 @@ import { GlobalDirectivesPlugin } from '@/app/plugins/directives'; import { createPinia, PiniaVuePlugin } from 'pinia'; import { ChartJSPlugin } from '@/app/plugins/chartjs'; import { SentryPlugin } from '@/app/plugins/sentry'; +import { registerVitePreloadErrorHandler } from '@/app/plugins/vitePreloadError'; import { registerModuleRoutes } from '@/app/moduleInitializer/moduleInitializer'; import { installRenderTracker } from '@/app/dev/render-tracker'; import type { VueScanOptions } from 'z-vue-scan'; +registerVitePreloadErrorHandler(); + const pinia = createPinia(); const app = createApp(App);