Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dc16aef
fix: Browser will freeze when sync request is intercepted
alexsch01 Nov 10, 2025
9182b4e
Update CHANGELOG.md
alexsch01 Nov 10, 2025
4801766
Update CHANGELOG.md
alexsch01 Nov 10, 2025
7b4f2e7
prevent cross origin cookies from breaking sync requests
alexsch01 Nov 11, 2025
02f6105
Update xmlHttpRequest.ts
alexsch01 Nov 12, 2025
7f0d6c6
Update xmlHttpRequest.ts
alexsch01 Nov 12, 2025
12a25e4
fix lint
alexsch01 Nov 12, 2025
6a21ef2
Merge branch 'develop' into fix-browser-freeze
alexsch01 Nov 14, 2025
a837527
Update intercepted-request.ts
alexsch01 Nov 18, 2025
3cdaa5f
Update response-middleware.ts
alexsch01 Nov 18, 2025
c27f137
Merge branch 'develop' into fix-browser-freeze
alexsch01 Nov 18, 2025
247da30
grammar fix
alexsch01 Nov 18, 2025
6664756
better warning
alexsch01 Nov 18, 2025
a9e67e1
Blank
alexsch01 Nov 18, 2025
5a65b0c
Update intercepted-request.ts
alexsch01 Nov 18, 2025
7d4a48b
Update response-middleware.ts
alexsch01 Nov 18, 2025
88c83e1
Update xmlHttpRequest.ts
alexsch01 Nov 18, 2025
29b4dc7
Merge branch 'develop' into fix-browser-freeze
jennifer-shehane Nov 19, 2025
775edee
Alexsch01 patch 1
alexsch01 Nov 19, 2025
844904d
Update xmlHttpRequest.ts
alexsch01 Nov 19, 2025
be6a22f
Update main.js
alexsch01 Nov 19, 2025
cda831d
this is correct now
alexsch01 Nov 20, 2025
f846e8e
move sync intercept and move patches
mschile Nov 20, 2025
da1d38f
Merge branch 'develop' into fix-browser-freeze
jennifer-shehane Nov 20, 2025
931b4fe
add unit test for proxy
alexsch01 Nov 21, 2025
82707fc
Create intercept_sync_request.cy.ts
alexsch01 Nov 21, 2025
cfabc45
Create sync_request_with_cookie.cy.ts
alexsch01 Nov 21, 2025
ad809b6
lint
alexsch01 Nov 21, 2025
48807a6
Update intercept_sync_request.cy.ts
alexsch01 Nov 22, 2025
8adc9d1
Update intercept_sync_request.cy.ts
alexsch01 Nov 22, 2025
39a2fa3
Update sync_request_with_cookie.cy.ts
alexsch01 Nov 22, 2025
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
4 changes: 4 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ _Released 12/2/2025 (PENDING)_

- Improved performance when viewing command snapshots in the Command Log. Element highlighting is now significantly faster, especially when highlighting multiple elements or complex pages. This is achieved by reducing redundant style calculations and batching DOM operations to minimize browser reflows. Addressed in [#32951](https://github.com/cypress-io/cypress/pull/32951).

**Bugfixes:**

- Fixed an issue where the browser will freeze when Cypress intercepts a synchronous request and a routeHandler is used. Fixes [#32874](https://github.com/cypress-io/cypress/issues/32874). Addressed in [#32925](https://github.com/cypress-io/cypress/pull/32925).

## 15.7.0

_Released 11/19/2025_
Expand Down
40 changes: 40 additions & 0 deletions packages/driver/cypress/e2e/e2e/intercept_sync_request.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// https://github.com/cypress-io/cypress/pull/32925
describe('intercept sync request', () => {
it('completes all the way with route handler', () => {
cy.intercept('/app', {
body: `
<!DOCTYPE html>
<html>
<head>
<title>Sync Request</title>
</head>
<body>
<div>
<button id="sync-request-button">Test Sync Request</button>
<div id="counter"></div>
</div>
<script>
const button = document.querySelector('#sync-request-button')
button.addEventListener('click', () => {
let xhr = new window.XMLHttpRequest()
xhr.open('GET', '/', false)
xhr.onload = () => console.log(xhr.status)
xhr.send()
})
let count = 0
setInterval(() => {
document.querySelector('#counter').innerHTML = count
count++
}, 100)
</script>
</body>
</html>
`,
})

cy.intercept('/', () => {})
cy.visit('/app')
cy.get('#sync-request-button').click()
cy.wrap(0).should('eq', 0)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// https://github.com/cypress-io/cypress/pull/32925
describe('Sync Request in cy.origin that sets cookie', () => {
it('passes', { browser: '!webkit' }, () => {
cy.intercept('https://foo.site.com', {
body: `
<!DOCTYPE html>
<html>
<body>
Page 1 / 2
</body>
</html>
`,
})

cy.visit('https://foo.site.com')

cy.intercept('https://test.site.com/sync', {
headers: {
'set-cookie': 'TEST=foo',
},
body: '',
})

cy.intercept('https://test.site.com/bar', {
body: `
<!DOCTYPE html>
<html>
<body>
Page 2 / 2
<script>
let xhr = new window.XMLHttpRequest()
xhr.open('GET', '/sync', false)
xhr.send()
</script>
</body>
</html>
`,
})

cy.origin('https://test.site.com', () => {
cy.visit('https://test.site.com/bar')
cy.wrap(0).should('eq', 0)
})
})
})
4 changes: 2 additions & 2 deletions packages/driver/src/cross-origin/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import { handleTestEvents } from './events/test'
import { handleMiscEvents } from './events/misc'
import { handleUnsupportedAPIs } from './unsupported_apis'
import { patchFormElementSubmit } from './patches/submit'
import { patchFetch } from '@packages/runner/injection/patches/fetch'
import { patchXmlHttpRequest } from '@packages/runner/injection/patches/xmlHttpRequest'
import { patchFetch } from '@packages/runner/injection/patches/cross-origin/fetch'
import { patchXmlHttpRequest } from '@packages/runner/injection/patches/cross-origin/xmlHttpRequest'

import $Mocha from '../cypress/mocha'

Expand Down
9 changes: 9 additions & 0 deletions packages/net-stubbing/lib/server/intercepted-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { BackendRoute, NetStubbingState } from './types'
import { emit, sendStaticResponse } from './util'
import type CyServer from '@packages/server'
import type { BackendStaticResponse } from '../internal-types'
import { styleText } from 'util'

export class InterceptedRequest {
id: string
Expand Down Expand Up @@ -77,6 +78,14 @@ export class InterceptedRequest {
continue
}

// if the request is sync and the route has an interceptor (i.e. routeHandler), then skip the intercept
// because the we cannot wait on the before:request event when the sync request is blocking
if (this.req.isSyncRequest && route.hasInterceptor) {
process.stdout.write(styleText('yellow', `WARNING: sync XHR request was not intercepted for url: ${this.req.proxiedUrl}\n`))

continue
}

const subscriptionsByRoute = {
routeId: route.id,
immediateStaticResponse: route.staticResponse,
Expand Down
5 changes: 5 additions & 0 deletions packages/proxy/lib/http/request-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ const ExtractCypressMetadataHeaders: RequestMiddleware = function () {

this.req.isAUTFrame = !!this.req.headers['x-cypress-is-aut-frame']
this.req.isFromExtraTarget = !!this.req.headers['x-cypress-is-from-extra-target']
this.req.isSyncRequest = !!this.req.headers['x-cypress-is-sync-request']

if (this.req.headers['x-cypress-is-aut-frame']) {
delete this.req.headers['x-cypress-is-aut-frame']
}

if (this.req.headers['x-cypress-is-sync-request']) {
delete this.req.headers['x-cypress-is-sync-request']
}

span?.setAttributes({
isAUTFrame: this.req.isAUTFrame,
isFromExtraTarget: this.req.isFromExtraTarget,
Expand Down
12 changes: 12 additions & 0 deletions packages/proxy/lib/http/response-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { hasServiceWorkerHeader, isVerboseTelemetry as isVerbose } from '.'
import { CookiesHelper } from './util/cookies'
import * as rewriter from './util/rewriter'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import { styleText } from 'util'

import type Debug from 'debug'
import type { CookieOptions } from 'express'
Expand Down Expand Up @@ -719,6 +720,17 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () {
return this.next()
}

// if the request is sync, we cannot wait on the cross:origin:cookies:received
// event since the sync request is blocking. This means that the cross-origin cookies
// may not have been applied.
if (this.req.isSyncRequest) {
process.stdout.write(styleText('yellow', `WARNING: cross-origin cookies may not have been applied for sync request: ${this.req.proxiedUrl}\n`))

span?.end()

return this.next()
}

// we want to set the cookies via automation so they exist in the browser
// itself. however, firefox will hang if we try to use the extension
// to set cookies on a url that's in-flight, so we send the cookies down to
Expand Down
1 change: 1 addition & 0 deletions packages/proxy/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type CypressIncomingRequest = Request & {
* Stack-ordered list of `cy.intercept()`s matching this request.
*/
matchingRoutes?: BackendRoute[]
isSyncRequest: boolean
}

export type RequestCredentialLevel = 'same-origin' | 'include' | 'omit' | boolean
Expand Down
22 changes: 22 additions & 0 deletions packages/proxy/test/unit/http/request-middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ describe('http/request-middleware', () => {
expect(ctx.req.isFromExtraTarget).toBe(false)
})
})

describe('x-cypress-is-sync-request', () => {
it('when it exists, removes header and sets in on the req', async () => {
const ctx = prepareContext({
'x-cypress-is-sync-request': 'true',
})

await testMiddleware([ExtractCypressMetadataHeaders], ctx)

expect(ctx.req.headers!['x-cypress-is-sync-request']).toBeUndefined()
expect(ctx.req.isSyncRequest).toBe(true)
})

it('when it does not exist, sets in on the req', async () => {
const ctx = prepareContext()

await testMiddleware([ExtractCypressMetadataHeaders], ctx)

expect(ctx.req.headers!['x-cypress-is-sync-request']).toBeUndefined()
expect(ctx.req.isSyncRequest).toBe(false)
})
})
})

describe('CalculateCredentialLevelIfApplicable', () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/runner/injection/cross-origin.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
/* global cypressConfig */

import { createTimers } from './timers'
import { patchDocumentCookie } from './patches/cookies'
import { patchElementIntegrity } from './patches/setAttribute'
import { patchFetch } from './patches/fetch'
import { patchXmlHttpRequest } from './patches/xmlHttpRequest'
import { patchDocumentCookie } from './patches/cross-origin/cookies'
import { patchElementIntegrity } from './patches/cross-origin/setAttribute'
import { patchFetch } from './patches/cross-origin/fetch'
import { patchXmlHttpRequest } from './patches/cross-origin/xmlHttpRequest'

const findCypress = () => {
for (let index = 0; index < window.parent.frames.length; index++) {
Expand Down
3 changes: 3 additions & 0 deletions packages/runner/injection/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { createTimers } from './timers'
import { patchXmlHttpRequest } from './patches/xmlHttpRequest'

const Cypress = window.Cypress = parent.Cypress

Expand All @@ -16,6 +17,8 @@ if (!Cypress) {
Cypress in the parent window but it is missing. This should never happen and likely is a bug. Please open an issue.')
}

patchXmlHttpRequest(window)

// We wrap timers in the injection code because if we do it in the driver (like
// we used to do), any uncaught errors thrown in the timer callbacks would
// get picked up by the top frame's 'error' handler instead of the AUT's.
Expand Down
51 changes: 51 additions & 0 deletions packages/runner/injection/patches/cross-origin/xmlHttpRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { captureFullRequestUrl, requestSentWithCredentials } from './utils'

export const patchXmlHttpRequest = (window: Window) => {
// intercept method calls and add cypress headers to determine cookie applications in the proxy
// for simulated top

const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open
const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send

window.XMLHttpRequest.prototype.open = function (...args) {
try {
// since the send method does NOT have access to the arguments passed into open or have the request information,
// we need to store a reference here to what we need in the send method
this._url = captureFullRequestUrl(args[1], window)
} finally {
const result = originalXmlHttpRequestOpen.apply(this, args as any)

if (args.length > 2 && !args[2]) {
this.setRequestHeader('x-cypress-is-sync-request', 'true')
this._isSyncRequest = true
} else {
this._isSyncRequest = false
}

return result
}
}

window.XMLHttpRequest.prototype.send = function (...args) {
// if the request is sync, we cannot wait on the requestSentWithCredentials
// function call since the sync request is blocking.
if (this._isSyncRequest) {
return originalXmlHttpRequestSend.apply(this, args)
}

return (async () => {
try {
// if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies
// if the option isn't set, we can imply the default as we know the "resourceType" in the proxy
await requestSentWithCredentials({
url: this._url,
resourceType: 'xhr',
credentialStatus: this.withCredentials,
})
} finally {
// if our internal logic errors for whatever reason, do NOT block the end user and continue the request
return originalXmlHttpRequestSend.apply(this, args)
}
})()
}
}
31 changes: 5 additions & 26 deletions packages/runner/injection/patches/xmlHttpRequest.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,13 @@
import { captureFullRequestUrl, requestSentWithCredentials } from './utils'

export const patchXmlHttpRequest = (window: Window) => {
// intercept method calls and add cypress headers to determine cookie applications in the proxy
// for simulated top

const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open
const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send

window.XMLHttpRequest.prototype.open = function (...args) {
try {
// since the send method does NOT have access to the arguments passed into open or have the request information,
// we need to store a reference here to what we need in the send method
this._url = captureFullRequestUrl(args[1], window)
} finally {
return originalXmlHttpRequestOpen.apply(this, args as any)
}
}
const result = originalXmlHttpRequestOpen.apply(this, args)

window.XMLHttpRequest.prototype.send = async function (...args) {
try {
// if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies
// if the option isn't set, we can imply the default as we know the "resourceType" in the proxy
await requestSentWithCredentials({
url: this._url,
resourceType: 'xhr',
credentialStatus: this.withCredentials,
})
} finally {
// if our internal logic errors for whatever reason, do NOT block the end user and continue the request
return originalXmlHttpRequestSend.apply(this, args)
if (args.length > 2 && !args[2]) {
this.setRequestHeader('x-cypress-is-sync-request', 'true')
}

return result
}
}