Skip to content

Livewire wire:model losing keystrokes, and how we fixed it ​

If you see Livewire wire:model losing keystrokes, check what your component does to the bound property while a request is in flight: when the server assigns that property, Livewire patches the new value back into the input even if the user is still typing in it. In FilamentCraft v1.40.0 we stopped assigning the property during a debounced commit, and in v1.40.1 we rendered text inputs with wire:model.unintrusive.blur so a focused field is never overwritten.

v1.40.1 then broke persistence for every text setting, which v1.40.2 fixed. This post walks through all three releases, because each one taught us something about how Livewire and Alpine share an input.

The FilamentCraft editor with a list of page sections on the left and a live page preview filling the rest of the screen
Every text setting in the sidebar commits to the draft while the preview updates, which is where the lost characters came from.

The symptom ​

An operator types into a section setting, pauses for about half a second, keeps typing, and some characters vanish. The caret jumps back, the field reverts to an older value, and the next keystroke appends to that older value.

Our bug report reproduced it with Playwright on the demo editor. Typing ABCDEFGHIJKLMNOP at 75 ms per character with one 500 ms pause after the third character ended with ABCFGHIJKLMNOP. The D and the E were gone. The request left carrying ABC, the user typed two more characters, and when the response landed the input was forced back to ABC.

Two characters per round trip on a local machine. The window is the debounce plus the request time, so on a slow connection it eats more. Typing without ever pausing hides it completely, since the debounce keeps resetting and no request is sent. That is why the bug looked intermittent for a long time.

Why the editor sends a request while you type ​

The settings panel binds its fields with live(onBlur: true), which Filament renders as wire:model.blur. On its own that binding does not open a request per keystroke. The editor adds one more path so that typing reaches the draft (and survives a reload) even if the field never loses focus.

In resources/js/editor.ts, a capture-phase input listener watches every element bound to data.*. It patches the iframe preview almost immediately, and schedules a server commit behind LIVE_SERVER_COMMIT_DEBOUNCE_MS, which is 400. When the timer fires it calls commitLiveSetting on the SettingsPanel Livewire component with the section id, the setting id and the value. The editor internals guide covers the flush that sends pending commits before a navigating click.

So every 400 ms of not typing opens a Livewire round trip while the field still has focus. That by itself is fine. What the server did with the value was not.

Where the characters went ​

Before v1.40.0, SettingsPanel::commitLiveSetting() ended by writing the value onto the component's data property and then persisting, and the persist step reassigned $this->data again with the sanitized settings. data is the schema's state path, so it is exactly the property the inputs are bound to.

On the response, Livewire compares the server snapshot against what the client last knew. A method call sends no property updates, so the server's data.text looked like a change. Livewire wrote it into the reactive state, Alpine's model effect ran, and the input's value was set to what the server had, which was the text as it stood when the request left. Anything typed during the round trip was overwritten.

Here is the pitfall in a plain Livewire component, no FilamentCraft involved:

php
use Livewire\Component;

class Headline extends Component
{
    public string $text = '';

    public function save(string $value): void
    {
        $this->text = $value; // patched back into the focused input on the response

        Post::query()->whereKey(1)->update(['headline' => $value]);
    }
}
blade
<input type="text" wire:model.blur="text"
       x-on:input.debounce.400ms="$wire.save($event.target.value)">

Type, pause, keep typing, and whatever you typed while save was on the wire gets replaced.

Fix one in v1.40.0: persist without touching the property ​

The v1.40.0 fix was to stop assigning the bound property in the debounced call. persistCurrentData() was split so the actual work moved into a private persistSettings(array $values): array that returns the sanitized settings. The normal lifecycle path still assigns the result. The live commit does not:

php
#[Renderless]
public function commitLiveSetting(string $sectionId, string $settingId, mixed $value): void
{
    // stale-section and schema guards...

    $this->persistSettings([...$this->data, $settingId => $value]);
}

#[Renderless] closes a second door: the call no longer re-renders the panel, so no morph can reach the field either. The DOM stays the source of truth until the field blurs. Leaving $this->data behind the DOM for one focused field is safe, because only one field can have focus, the call merges that field explicitly, and every other field already synced its value on blur.

The generic version of the fix looks like this:

php
use Livewire\Attributes\Renderless;

#[Renderless]
public function save(string $value): void
{
    Post::query()->whereKey(1)->update(['headline' => $value]);
}

The alternative we rejected ​

The obvious other fix was to drop the timer and commit only on blur. Our report tested that idea and rejected it. The debounced call is the only thing that writes typing into the draft while the field keeps focus, so blur-only commits would swap dropped characters for dropped edits when someone reloads or closes the tab without clicking away.

Fix two in v1.40.1: wire:model.unintrusive.blur ​

v1.40.0 closed the door the editor's own commit used, but the same clobber could reach the field through any other request that changed data: an auto-save tick, or a sibling component's draft sync re-hydrating the panel. Livewire patches a changed property into its input regardless of focus, so fixing one caller was never going to cover every caller.

Alpine has a check for exactly this. In its x-model source, the effect that writes the model value into the element returns early when the unintrusive modifier is present and the element is document.activeElement. It is not in the list of modifiers on the Alpine x-model docs page, and the Livewire wire:model docs do not mention it either, so treat it as source-level behaviour and test it on your Livewire version.

In SchemaCompiler::bindOnBlur(), text inputs and textareas now get the modifier through Filament's stateBindingModifiers():

php
$input->live(onBlur: true);

if ($input instanceof TextInput || $input instanceof FilamentTextarea) {
    $input->stateBindingModifiers(['unintrusive', 'blur']);
}

They render as wire:model.unintrusive.blur. Select, toggle, checkbox and colour picker keep their existing bindings, since they change on discrete events and are not being typed into. The rich editor was left alone because it is entangled, and the modifier would reach $entangle(), which has no such opt-out.

For the generic component, the Blade side becomes:

blade
<input type="text" wire:model.unintrusive.blur="text"
       x-on:input.debounce.400ms="$wire.save($event.target.value)">

The v1.40.1 regression ​

Adding a modifier to a binding sounds harmless. It was not. The editor bundle found text settings by matching four literal wire:model spellings, and wire:model.unintrusive.blur was not one of them. The input listener stopped seeing text settings at all, the debounced draft commit never ran, and an edit typed without clicking away was lost on reload. The preview also stopped following the typing.

v1.40.2 replaced the literal list with a function that reads the binding off whatever modifier tail is rendered. liveTargetSettingId() walks the element's attributes, accepts wire:model or anything starting with wire:model., and takes the setting id from a value that starts with data.:

ts
function liveTargetSettingId(target: HTMLElement): string | null {
  for (const attribute of Array.from(target.attributes)) {
    if (attribute.name !== 'wire:model' && !attribute.name.startsWith('wire:model.')) continue;
    if (!attribute.value.startsWith('data.')) continue;

    const settingId = attribute.value.slice('data.'.length);
    if (settingId !== '') return settingId;
  }

  return null;
}

A future modifier can no longer break the pairing between the server binding and the client listener. If your own JavaScript finds Livewire inputs by attribute name, match on the prefix for the same reason.

What to check in your own components ​

Three questions cover most cases of lost keystrokes:

  • Does any action that can run while a field has focus assign the property that field is bound to? If so, persist from a local value and leave the property alone.
  • Does the action need to re-render? If not, #[Renderless] removes the morph as a second path to the input.
  • Can some other request (polling, events from sibling components, auto-save) change that property mid-typing? If so, the unintrusive modifier protects the focused element regardless of which request caused the change.

The full version history for these fixes is in the changelog, and the editor guide describes how live preview and autosave behave from the operator's side. You can try typing into a section setting yourself on the live demo.

Last updated: