Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
25 changes: 25 additions & 0 deletions npm/vite-dev-server/client/initCypressTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,31 @@ if (supportFile) {

const relativeUrl = `${devServerPublicPathBase}${supportRelativeToProjectRoot}`

// Handle support file import failures using Vite's vite:preloadError event.
// This event fires when dynamic imports fail, typically due to stale assets
// after switching between spec files (page reloads). When this occurs, we
// prevent the default error and reload the page to fetch fresh assets.
let preloadErrorHandled = false

window.addEventListener('vite:preloadError', (event) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is partially right, where we want to do a window.location.reload() in the case where we fail to dynamically import a module, but vite:preloadError only exists in production builds and here we are hosting on the dev server. Therefore, this event is never called.

A spot that could make sense would be https://github.com/cypress-io/cypress/blob/develop/packages/driver/src/cypress/script_utils.ts#L39 where we can catch if the load function fails and then reload, but it's not super trivial . There could still be timing issues related to reloading the page vs when the dev server is actually ready. But we can likely tune it to be a best guess. It usually doesn't take a long time.

I have been playing around with this on 4cd2daa / chore/spike_into_vite_dynamic_load:

    if (script.load) {
      return new Promise((resolve, reject) => {
        script.load().then(resolve).catch((e) => {
          // if we fail to import the script, likely due to the vite dev server optimizing dependencies, then we need to reload the page
          if (e.message.includes('Failed to fetch dynamically imported module') && script.isViteDevServerImport) {
            // there could be a syncing issue between the time the dev server takes to reload vs
            // the hardcoded 1 second timeout, but reloads are unlikely to take longer than 1 second
            return setTimeout(() => {
              window.location.reload()
              resolve(undefined)
            }, 1000)
          }

          return reject(e)
        })
      })
    }

I think something like this might actually work for what we need. We can't retry the import() because the result of the failure is cached, so the best result is really to just reload the page

// Only handle the first preload error to avoid infinite reload loops
if (preloadErrorHandled) {
return
}

const error = event.payload
const errorUrl = error?.url || ''

// Only handle errors related to the support file import
// Check if the error URL matches our support file URL
if (errorUrl && (errorUrl.includes(supportRelativeToProjectRoot) || errorUrl === relativeUrl)) {
preloadErrorHandled = true
event.preventDefault()
// Reload the page to fetch fresh assets
window.location.reload()
}
}, { once: false })

importsToLoad.push({
load: () => import(relativeUrl),
absolute: supportFile,
Expand Down
151 changes: 149 additions & 2 deletions npm/vite-dev-server/test/initCypressTests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ describe('initCypressTests', () => {

mockCypressInstance = createMockCypress()

global.import = vi.fn()
;(global as any).import = vi.fn()
// @ts-expect-error
global.window = {}
global.window = {
addEventListener: vi.fn(),
location: { reload: vi.fn() } as any,
}

// @ts-expect-error
global.parent = {}
// @ts-expect-error
Expand Down Expand Up @@ -199,4 +203,147 @@ describe('initCypressTests', () => {
})
})
})

describe('support file vite:preloadError handling', () => {
// The vite:preloadError handling is tested through:
// - Integration tests in system-tests that exercise the actual Vite dev server
//
// These tests verify that the event listener structure is in place and the
// load function is simplified (no retry logic).

it('creates a simple load function for support file (no retry logic)', async () => {
await import('../client/initCypressTests.js')

const calls = mockCypressInstance.onSpecWindow.mock.calls
const supportFileLoader = calls[0][1][0].load

// Verify the load function exists and is a function
expect(supportFileLoader).toBeDefined()
expect(typeof supportFileLoader).toBe('function')

// The load function should return a promise (from import())
const loadPromise = supportFileLoader()

expect(loadPromise).toBeInstanceOf(Promise)

// Await and catch any errors to prevent unhandled promise rejections
// In this unit test environment, the import will likely fail since the URL
// won't resolve, but we just need to verify the function returns a promise
try {
await loadPromise
} catch (error) {
// Expected to fail in unit test environment - ignore
}
})

it('registers vite:preloadError event listener', async () => {
await import('../client/initCypressTests.js')

// Verify that addEventListener was called with 'vite:preloadError'
expect((global.window as any).addEventListener).toHaveBeenCalledWith(
'vite:preloadError',
expect.any(Function),
{ once: false },
)
})

it('handles vite:preloadError for support file imports', async () => {
await import('../client/initCypressTests.js')

// Get the event listener that was registered
const addEventListenerCalls = ((global.window as any).addEventListener as any).mock.calls
const preloadErrorHandler = addEventListenerCalls.find(
(call: any[]) => call[0] === 'vite:preloadError',
)?.[1]

expect(preloadErrorHandler).toBeDefined()
expect(typeof preloadErrorHandler).toBe('function')

// Create a mock event with payload matching support file URL
const mockEvent = {
payload: {
url: '/__cypress/src/cypress/support/component.js',
message: 'Failed to fetch dynamically imported module',
},
preventDefault: vi.fn(),
}

// Call the handler
preloadErrorHandler(mockEvent)

// Verify preventDefault was called
expect(mockEvent.preventDefault).toHaveBeenCalled()

// Verify reload was called
expect((global.window as any).location.reload).toHaveBeenCalled()
})

it('ignores vite:preloadError for non-support file imports', async () => {
await import('../client/initCypressTests.js')

// Get the event listener that was registered
const addEventListenerCalls = ((global.window as any).addEventListener as any).mock.calls
const preloadErrorHandler = addEventListenerCalls.find(
(call: any[]) => call[0] === 'vite:preloadError',
)?.[1]

expect(preloadErrorHandler).toBeDefined()

// Create a mock event with payload for a different file
const mockEvent = {
payload: {
url: '/__cypress/src/@fs/some/other/file.js',
message: 'Failed to fetch dynamically imported module',
},
preventDefault: vi.fn(),
}

// Call the handler
preloadErrorHandler(mockEvent)

// Verify preventDefault was NOT called (error not for support file)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()

// Verify reload was NOT called
expect((global.window as any).location.reload).not.toHaveBeenCalled()
})

it('prevents infinite reload loops by only handling first error', async () => {
await import('../client/initCypressTests.js')

// Get the event listener that was registered
const addEventListenerCalls = ((global.window as any).addEventListener as any).mock.calls
const preloadErrorHandler = addEventListenerCalls.find(
(call: any[]) => call[0] === 'vite:preloadError',
)?.[1]

expect(preloadErrorHandler).toBeDefined()

const mockEvent1 = {
payload: {
url: '/__cypress/src/cypress/support/component.js',
},
preventDefault: vi.fn(),
}

const mockEvent2 = {
payload: {
url: '/__cypress/src/cypress/support/component.js',
},
preventDefault: vi.fn(),
}

// Call handler twice with same support file error
preloadErrorHandler(mockEvent1)
preloadErrorHandler(mockEvent2)

// First call should handle it
expect(mockEvent1.preventDefault).toHaveBeenCalled()
expect((global.window as any).location.reload).toHaveBeenCalledTimes(1)

// Second call should be ignored (preloadErrorHandled flag)
expect(mockEvent2.preventDefault).not.toHaveBeenCalled()
expect((global.window as any).location.reload).toHaveBeenCalledTimes(1)
})
})
})
4 changes: 2 additions & 2 deletions system-tests/test/vite_dev_server_fresh_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('@cypress/vite-dev-server', function () {
project,
configFile: 'cypress-vite.config.ts',
testingType: 'component',
browser: 'chrome',
browser: 'electron',
snapshot: true,
expectedExitCode: 7,
})
Expand All @@ -28,7 +28,7 @@ describe('@cypress/vite-dev-server', function () {
configFile: 'cypress-vite-port.config.ts',
spec: 'src/port.cy.jsx',
testingType: 'component',
browser: 'chrome',
browser: 'electron',
snapshot: true,
expectedExitCode: 0,
})
Expand Down
Loading