In the process of making a minimal example to file a bug, of course I realised what I was doing that caused the issue. I am still going to file a feedback, in case someone else does this kind of weird workaround and doesn't notice that it breaks VO.
I wanted the button to be bordered, to match my other buttons, and the only way I found to do that was to have the actual button as the label of a SwiftUI button, like so:
Button() {
} label: {
//The device picker has a lot more padding than a typical SF Symbols image, so we have to give it negative padding so that the icon and button look about the same size as the other buttons
DevicePickerView().padding(-10).frame(width: devicePickerSize, height: devicePickerSize)
}.buttonStyle(.bordered)
With the button inside another button like this, it works fine without VoiceOver (edit: I thought it was working on macOS with VoiceOver, but I actually didn't do this weird button-in-button thing on macOS), but not on iOS with VoiceOver.
When I just use the DevicePickerView by itself, it also works on iOS with VoiceOver, but I can't figure out how to get the bordered appearance, given that routePickerButtonStyle and isRoutePickerButtonBordered are macOS only, and DevicePickerView().buttonStyle(.bordered) doesn't seem to do anything.
For now I'll just forget about the border because it's more important to have it working than looking nice. But my new question is, how can I make a bordered version of a route picker view?
Edit: I can fake it fairly well with .background(Color.secondary.opacity(0.2)).containerShape(RoundedRectangle(cornerRadius: 8)) but I don't like those magic numbers in there.