livewire/livewire: Pull request #10456: [4.x] Handle empty successful responses
Contributed by joshhanley
Part of the livewire v4.4.7 release
Pull request #10456 fixes a disorienting failure mode that could affect any Livewire interaction: when a request resolves with a successful HTTP status but an empty body — most commonly on Safari after the browser has been suspended and resumed — Livewire used to display a completely blank, full-screen error modal. This PR detects empty successful responses before any parsing happens and routes them through the existing error-response pipeline, which now surfaces a concise "would you like to refresh?" confirmation instead of an empty dialog. If you're upgrading from v3.8.10, this is a pure behavior fix: nothing to configure, no API changes, and a new interception hook if you want to customize the experience.
Category: Fix (id 3) · Fixes #10424
What changed
Two additions in js/request/index.js, plus new browser tests in Features/SupportErrorResponses/BrowserTest.php:
- Message path. After the existing success/failure checks in
sendMessages(), a new guard matches responses whereresponse.okis true but the body is empty (responseBody.trim() === ''). Instead of continuing, Livewire shows a nativeconfirm()dialog — "The server returned an empty response. Would you like to refresh the page?" — and reloads the page when the user accepts. The blank HTML modal is never rendered. - Request pipeline. In
sendRequest(), an empty body now short-circuits before JSON parsing:handlers.error({ response, responseBody })andhandlers.finish()are invoked, and the function returns early. This is what funnels the empty response into the standard error flow, where request interceptors can react.
Why it matters
The trigger requires nothing exotic. A suspended-and-resumed Safari can deliver a 200 with zero bytes of body for an ordinary update request, so even a trivial component is exposed:
<div>
<p>{{ $count }}</p>
<button wire:click="increment">Increment</button>
</div>
Previously, Livewire saw the 200, treated the request as successful, and tried to parse the empty body as JSON. Parsing failed, the raw response was handed to the normal HTML error handler, and showHtmlModal() rendered a full-screen dialog containing nothing — no message, no details, no recovery path.
Automatically retrying was never a safe option: Livewire cannot know whether the action reached the server before its response was lost. A blind retry could double-apply effects — increment a counter twice, submit two orders, fire two notifications.
The new default: refresh, don't retry
The default experience now mirrors the session-expired (419) flow. The user sees a short confirmation explaining that the server returned an empty response and offering to refresh the page. Accepting reloads the page, which is always safe: the reloaded page reflects the server's authoritative state, whether or not the original action actually ran. Declining simply dismisses the dialog — no blank modal, no dead end.
Customizing with a request interceptor
Request interceptors receive the response before the default confirmation fires, and calling preventDefault() suppresses it so you can substitute your own UX:
<script>
document.addEventListener('livewire:init', () => {
Livewire.interceptRequest(({ onError }) => {
onError(({ response, body, preventDefault }) => {
if (response.ok && body === '') {
toast('Connection hiccup — reloading…')
setTimeout(() => window.location.reload(), 800)
preventDefault()
}
})
})
})
</script>
The handler receives { response, body, preventDefault }. Checking response.ok && body === '' isolates this specific failure so your other error handling — 419 dialogs, HTML error modals, server exceptions — remains untouched.
Upgrading from v3.8.10
- Nothing is required. The detection and the default dialog are automatic once you're on a release containing this PR.
- Audit existing error hooks. Because empty successful responses now flow through
handlers.error(...), anyonErrorinterceptors you've registered will start receiving this case. Make sure they tolerate — or explicitly skip — empty bodies. - Remove old workarounds. If you patched around the blank modal with global fetch wrappers, custom modal styling, or "empty response" checks, you can likely delete that code.
- Custom update routes. If you stub the update endpoint via
Livewire::setUpdateRoute()and return an empty200(unusual, but common in tests), expect the refresh dialog — or your interceptor's handling — instead of a blank modal.
Takeaways
- Empty
200responses no longer produce a blank HTML error modal. - The default is a native confirmation offering a page refresh, consistent with the 419 flow.
- Customize or suppress it with
Livewire.interceptRequest()→onError→preventDefault(). - There is deliberately no auto-retry: the action may have already run, and a refresh restores the server's truth.
- Upgrading is friction-free — no config, no API changes, just better failure behavior.