After a few days of research, I've discovered Visualforce pages are hosted inside of an iframe when you switch to the Lightning Experience. Force events are handled by the main page, and events raised inside of the VF page can't escape the iframe. You can find out more here.
Fortunately, there's a work-around. Browsers provide a way to post messages between a page in an iframe and its host window. Lightning Out takes advantage of this capability to allow code in a Visuaforce host page to post events to the main page running Lightning. You just need to wire things up in the $Lightning.createComponent() method in the VF page.
The first step is to add the force namespace as a dependency in your dependency app. This will pull in the force events so you can fire them from your components.
depApp.app
<aura:application access="GLOBAL" extends="ltng:outApp">
<aura:dependency resource="c:someComponent"/>
<aura:dependency resource="markup://force:*" type="EVENT"/>
</aura:application>
The second step is to wire up listeners in your VF page to respond to these events by forwarding them to the host Lightning app. There are two ways you can do this. One is supported; the other is not.
sforce.one
Lightning Out exposes a number of navigation events via an object sforce.one. It is a wrapper around internal code that uses the browser's window.postMessage() function to forward events to the host page. For example, if you want to navigate to the Account object's home view, you could run the code below.
sforce.one.navigateToObjectHome('Account');
SfdcApp
While very useful, sforce.one doesn't expose all the possible force events. One of those not available is force:showToast. There is an object internal to Lightning Out called SfdcApp that exposes a way to raise events up to the host page. I'm pretty sure this is unsupported so use at your discretion.
SfdcApp.projectOneNavigator.fireContainerEvent('force:showToast', { message: 'Save Successful!' });
The final piece is to wire up listeners in the VF page to forward these calls to the host. There are two things to note in the code below. One, the target component, c:someComponent, implements force:hasRecordId and needs a record ID. I show how to get that from the VF page. Two, SfdcApp is unsupported as far as I know, so I created a helper function postEventToOneApp that takes a fallback action in case SfdcApp does not exist. This helps to future proof the code.
<apex:page standardcontroller="someComponent__c">
<!-- This page is a host page for the someComponent lightning component. -->
<apex:includeLightning />
<div class="slds">
<div id="someComponent"></div>
</div>
<script>
var recordId = '{!someComponent__c.id}';
$Lightning.use("c:depApp", function() {
var attributes = {
recordId: recordId
};
var targetElementId = 'someComponent';
var postEventToOneApp = function(name, params, fallbackAction) {
if (SfdcApp && SfdcApp.projectOneNavigator && SfdcApp.projectOneNavigator.fireContainerEvent) {
// Not officially supported by Salesforce
SfdcApp.projectOneNavigator.fireContainerEvent(name, params);
} else if (fallbackAction) {
// Fallback if no Salesforce support of fireContainerEvent
fallbackAction();
}
};
$Lightning.createComponent('c:someComponent', attributes, targetElementId,
function(cmp) {
// Add two handlers. One for showing toasts and another to navigate to object home.
$A.eventService.addHandler({
event: 'force:showToast',
handler: function(event) {
postEventToOneApp('force:showToast', event.$params$, function() {
alert(event.$params$.message);
});
}
});
$A.eventService.addHandler({
event: 'force:navigateToObjectHome',
handler: function(event) {
postEventToOneApp('force:navigateToObjectHome', event.$params$, function() {
sforce.one.navigateToObjectHome(event.$params$.scope);
});
}
});
});
});
</script>
</apex:page>