Skip to content
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
44 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
5645c1a
test updates
mschile Dec 2, 2025
9317f69
test updates
mschile Dec 2, 2025
ef57a66
Merge branch 'develop' into fix-browser-freeze
mschile Dec 2, 2025
e1f4ca0
adding system test
mschile Dec 2, 2025
4bb4ec4
update warnings
mschile Dec 2, 2025
137b3c3
Merge branch 'develop' into fix-browser-freeze
mschile Dec 2, 2025
a7fbcc6
fix lint
mschile Dec 2, 2025
d4f7972
update to use errors package
mschile Dec 3, 2025
af024cb
Merge branch 'develop' into fix-browser-freeze
mschile Dec 3, 2025
eded2cf
Merge branch 'develop' into fix-browser-freeze
mschile Dec 4, 2025
601615f
test update
mschile Dec 4, 2025
f4285f0
update test
mschile Dec 4, 2025
8526514
slight correction
alexsch01 Dec 4, 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
8 changes: 8 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 15.7.2

_Released 12/16/2025 (PENDING)_

**Bugfixes:**

- Fixed an issue where the browser would freeze when Cypress intercepts a synchronous XHR 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.1

_Released 12/2/2025_
Expand Down
141 changes: 141 additions & 0 deletions packages/driver/cypress/e2e/commands/net_stubbing.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,147 @@ describe('network stubbing', { retries: 15 }, function () {
})
})

it('does not intercept an XHR sync request with a route handler', () => {
cy.intercept('/', {
body: `
<!DOCTYPE html>
<html>
<body>
<div id="sync_response"></div>
<div id="async_response"></div>
<script>
const xhr = new window.XMLHttpRequest()
xhr.open('GET', '/fixtures/valid.json', false)
xhr.send()
document.querySelector('#sync_response').innerHTML = JSON.stringify(JSON.parse(xhr.responseText))
const xhr2 = new window.XMLHttpRequest()
xhr2.open('GET', '/async', true)
xhr2.onload = () => {
document.querySelector('#async_response').innerHTML = xhr2.responseText
}
xhr2.send()
</script>
</body>
</html>
`,
})

// this intercept won't get hit because the request is sync
cy.intercept('/fixtures/valid.json', () => {}).as('sync')

// this intercept will get hit because the request is async
cy.intercept('/async', (req) => {
req.reply({ body: 'async' })
}).as('async')

cy.visit('/')
cy.get('#sync_response').should('contain', '{"foo":1,"bar":{"baz":"cypress"}}')
cy.get('#async_response').should('contain', 'async')
cy.wait('@async')
})

it('does not intercept a cross-origin XHR sync request with a route handler', { browser: '!webkit' }, () => {
cy.intercept('http://www.foobar.com:3500/', {
body: `
<!DOCTYPE html>
<html>
<body>
<div id="sync_response"></div>
<div id="async_response"></div>
<script>
const xhr = new window.XMLHttpRequest()
xhr.open('GET', '/fixtures/valid.json', false)
xhr.send()
document.querySelector('#sync_response').innerHTML = JSON.stringify(JSON.parse(xhr.responseText))
const xhr2 = new window.XMLHttpRequest()
xhr2.open('GET', '/async', true)
xhr2.onload = () => {
document.querySelector('#async_response').innerHTML = xhr2.responseText
}
xhr2.send()
</script>
</body>
</html>
`,
})

// this intercept won't get hit because the request is sync
cy.intercept('http://www.foobar.com:3500/fixtures/valid.json', (req) => {
req.reply({ body: '' })
})

// this intercept will get hit because the request is async
cy.intercept('http://www.foobar.com:3500/async', (req) => {
req.reply({ body: 'async' })
}).as('async')

cy.origin('http://www.foobar.com:3500', () => {
cy.visit('http://www.foobar.com:3500/')
cy.get('#sync_response').should('contain', '{"foo":1,"bar":{"baz":"cypress"}}')
cy.get('#async_response').should('contain', 'async')
})

cy.wait('@async')
})

it('intercepts a sync XHR request from a Worker with a route handler', () => {
cy.intercept('/', {
body: `
<!DOCTYPE html>
<html>
<body>
<div id="sync_response"></div>
<div id="async_response"></div>
<script>
const workerScript = \`
const xhr = new XMLHttpRequest()
xhr.open('GET', 'http://localhost:3500/fixtures/valid.json', false)
xhr.send()
postMessage({ type: 'sync', data: JSON.stringify(JSON.parse(xhr.responseText)) })
const xhr2 = new XMLHttpRequest()
xhr2.open('GET', 'http://localhost:3500/async', true)
xhr2.onload = () => {
postMessage({ type: 'async', data: xhr2.responseText })
}
xhr2.send()
\`
const blob = new Blob([workerScript], { type: 'application/javascript' })
const workerURL = URL.createObjectURL(blob)
const worker = new Worker(workerURL)
worker.onmessage = (m) => {
if (m.data.type === 'sync') {
document.querySelector('#sync_response').innerHTML = m.data.data
} else if (m.data.type === 'async') {
document.querySelector('#async_response').innerHTML = m.data.data
}
}
</script>
</body>
</html>
`,
})

// this intercept will get hit because the sync XHR request is sent from the worker
cy.intercept('http://localhost:3500/fixtures/valid.json', (req) => {
req.reply({ body: '{"foo":"bar"}' })
}).as('sync')

// this intercept will get hit because the request is async
cy.intercept('http://localhost:3500/async', (req) => {
req.reply({ body: 'async' })
}).as('async')

cy.visit('/')
cy.get('#sync_response').should('contain', '{"foo":"bar"}')
cy.get('#async_response').should('contain', 'async')
cy.wait('@sync')
cy.wait('@async')
})

context('overrides', function () {
it('chains middleware with string matcher as expected', function () {
const e: string[] = []
Expand Down
58 changes: 58 additions & 0 deletions packages/driver/cypress/e2e/e2e/origin/cookie_misc.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,64 @@ describe('misc cookie tests', { browser: '!webkit' }, () => {
})
})

it('cookies are not set for XHR sync requests', { browser: '!webkit' }, () => {
cy.intercept('http://www.foobar.com:3500/', {
body: `
<!DOCTYPE html>
<html>
<body>
<div id="response"></div>
<script>
let xhr = new window.XMLHttpRequest()
xhr.open('GET', '/fixtures/valid.json', false)
xhr.send()
document.querySelector('#response').innerHTML = JSON.stringify(JSON.parse(xhr.responseText))

let xhr2 = new window.XMLHttpRequest()
xhr2.open('GET', '/async', true)
xhr2.send()
</script>
</body>
</html>
`,
})

// this intercept won't get hit because the request is sync
cy.intercept('http://www.foobar.com:3500/fixtures/valid.json', (req) => {
req.reply({
headers: {
'set-cookie': 'SYNC_COOKIE=sync',
},
body: '',
})
})

cy.intercept('http://www.foobar.com:3500/async', {
headers: {
'set-cookie': 'ASYNC_COOKIE=async',
},
body: '',
}).as('async')

cy.visit('http://localhost:3500/fixtures/empty.html')
cy.origin('http://www.foobar.com:3500', () => {
cy.visit('http://www.foobar.com:3500/')
cy.get('#response').should('contain', '{"foo":1,"bar":{"baz":"cypress"}}')
})

// cy.getAllCookies does not wait for the cookies to be set, so we need to wait manually
cy.wait(500)

// only the async cookie will be set
cy.getAllCookies().then((cookies) => {
expect(cookies).to.have.length(1)
expect(cookies[0].name).to.eq('ASYNC_COOKIE')
expect(cookies[0].value).to.eq('async')
})

cy.wait('@async')
})

/**
* FIXES:
* https://github.com/cypress-io/cypress/issues/25205 (cookies set with expired time with value deleted show up as set with value deleted)
Expand Down
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 chalk from 'chalk'

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(chalk.yellow(`WARNING: synchronous XHR request was not intercepted: ${this.req.proxiedUrl}\n`))

continue
}

const subscriptionsByRoute = {
routeId: route.id,
immediateStaticResponse: route.staticResponse,
Expand Down
1 change: 1 addition & 0 deletions packages/net-stubbing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"tslint": "tslint --config ../ts/tslint.json --project ."
},
"dependencies": {
"chalk": "^4.1.2",
"debug": "^4.4.0",
"is-html": "^2.0.0",
"istextorbinary": "6.0.0",
Expand Down
10 changes: 10 additions & 0 deletions packages/proxy/lib/http/request-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { doesTopNeedToBeSimulated } from './util/top-simulation'
import type { HttpMiddleware } from './'
import type { CypressIncomingRequest } from '../types'
import { urlMatchesOriginProtectionSpace } from '@packages/network-tools'
import chalk from 'chalk'

// do not use a debug namespace in this file - use the per-request `this.debug` instead
// available as cypress-verbose:proxy:http
Expand All @@ -34,11 +35,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 Expand Up @@ -220,6 +226,10 @@ const MaybeAttachCrossOriginCookies: RequestMiddleware = function () {
return this.next()
}

if (this.req.isSyncRequest) {
process.stdout.write(chalk.yellow(`WARNING: cross-origin cookies may not have been applied to synchronous XHR request: ${this.req.proxiedUrl}\n`))
}

// Top needs to be simulated since the AUT is in a cross origin state. Get the "requested with" and credentials and see what cookies need to be attached
const currentAUTUrl = this.getAUTUrl()
const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(this.req.proxiedUrl, currentAUTUrl, this.req.resourceType, this.req.credentialsLevel, this.req.isAUTFrame)
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 chalk from 'chalk'

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(chalk.yellow(`WARNING: cross-origin cookies may not have been set for synchronous XHR response: ${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
Loading