[3.x] diff() emits invalid root update paths ('' and '.key') → "Public property [$] not found", wedging components after a deploy
Livewire version
v3.8.1 — but I confirmed the faulty code is unchanged in v3.8.4 and on the current 3.x branch head.
Laravel version
11.51.0
PHP version
8.2.30
Browser and operating system
Chrome / macOS (not browser-specific — the failure is in the shared JS payload builder)
Describe the issue you're experiencing
diff() in js/utils.js can emit invalid top-level property paths — either the empty string '' or a leading-dot path like '.foo'. Both are rejected by the server, which surfaces as:
Unable to set component data. Public property [$] not found on component: [some.component]The $ isn't a property name. PublicPropertyNotFoundException interpolates "[\${$property}]", so an empty property name renders as [$]. Server-side, HandleComponents::updateProperty() does array_shift(explode('.', $path)), which yields '' for both malformed paths and throws.
Two branches are responsible. At the top level path is '':
https://github.com/livewire/livewire/blob/3.x/js/utils.js
// Did the key order change?
if (isObject(left) && leftKeys.length === rightKeys.length && leftKeys.some((key, i) => key !== rightKeys[i])) {
diffs[path] = right // path === '' at the root -> diffs[''] = <entire state>
return diffs
}
// Mark any items for removal...
leftKeys.forEach(key => {
diffs[`${path}.${key}`] = '__rm__' // path === '' at the root -> '.key'
})Why it happens in production. canonical is replaced wholesale from the server snapshot, but Component.mergeNewSnapshot() only assigns dirty root keys onto the existing ephemeral object — and JS appends unknown keys at the end. So if a deploy adds a public property that the server snapshot lists mid-order, the browser ends up with the same keys in a different order. The key-order branch then fires and posts the entire component state under ''.
The result is not a one-off error: every subsequent commit re-sends the same malformed diff, so the component is wedged until a full page reload. Any user holding the page open across a deploy is affected, which makes it look random and unreproducible in local testing.
I hit this four times in production over five months before tracking it down; the most recent occurrence followed a deploy that added one Volt state() key to an open form.
Relationship to #10001 / #10004 (this looks like an oversight, not a design decision)
The key-order branch was introduced by #10001 ([3.x] Fix diff() not detecting key order changes), merged 2026-02-14 — the last commit to touch js/utils.js on 3.x.
Its companion #10004 ([4.x] Fix diffRecursive() not detecting key order changes), merged the same day, adds the identical branch to diffRecursive() with the guard this report is asking for:
if (isObject(left) && leftKeys.length === rightKeys.length && leftKeys.some((key, i) => key !== rightKeys[i])) {
if (path !== '') { // <-- present on 4.x, missing on 3.x
diffs[path] = dataGet(rootRight, path)
return { changed: true, consolidated: true }
}
}So Livewire 4 is not affected: getUpdates() builds the payload with diffAndConsolidate()/diffRecursive(), which guards path !== '' at every consolidation branch and builds removal paths as path === '' ? key : \${path}.${key}`. On 3.x, Commit.toRequestPayload()still calls the unguardeddiff()`.
Two smaller notes:
- The legacy
diff()onmainis still unguarded in both branches, even thoughdiffRecursive()right below it is guarded. It no longer builds the outgoing payload, so it is not user-visible there — but it is a live trap for anyone reusing it. - #10071 later removed the
leftKeys.length === rightKeys.lengthprecondition fromdiff()onmain("includes insertions that shift existing keys"), which widens exactly the condition described here.
Code snippets to reproduce the issue
diff() is self-contained, so the malformed output can be shown directly against the released source — no app needed:
curl -sO https://raw.githubusercontent.com/livewire/livewire/v3.8.4/js/utils.js
mv utils.js lw-utils.mjs// repro.mjs
import { diff } from './lw-utils.mjs';
// A deploy inserts property `b`. Server snapshot order: a, b, c.
// The browser appended `b` at the end, because mergeNewSnapshot() assigns
// new root keys onto the existing ephemeral object.
console.log('key ORDER drift ->', JSON.stringify(diff({a:1,b:2,c:3}, {a:1,c:3,b:2})));
// A deploy removes property `b`.
console.log('key REMOVED ->', JSON.stringify(diff({a:1,b:2}, {a:1})));
// Control: an ordinary change.
console.log('normal change ->', JSON.stringify(diff({a:1}, {a:9})));$ node repro.mjs
key ORDER drift -> {"":{"a":1,"c":3,"b":2}}
key REMOVED -> {".b":"__rm__"}
normal change -> {"a":9}The first two are posted as updates keys. Server-side array_shift(explode('.', $path)) turns both '' and '.b' into the property name '', producing Public property [$] not found.
To reproduce end-to-end in an app:
- Component with
public $a = 1; public $c = 3;and anywire:modelinput. - Load the page and leave it open.
- Add
public $b = 2;between the two existing properties, so the snapshot order changes. - Interact with the input. Every commit from that tab now 500s until reload.
How do you expect it to work?
diff() should never produce a path that isn't a valid property path. Applying the same guard #10004 already uses on 4.x:
// Did the key order change?
if (isObject(left) && leftKeys.length === rightKeys.length && leftKeys.some((key, i) => key !== rightKeys[i])) {
if (path !== '') {
diffs[path] = right
return diffs
}
// At the root, emit per-property diffs instead of one '' blob.
Object.keys(right).forEach(key => {
if (JSON.stringify(left[key]) !== JSON.stringify(right[key])) diffs[key] = right[key]
})
return diffs
}
// Mark any items for removal...
leftKeys.forEach(key => {
diffs[path === '' ? key : `${path}.${key}`] = '__rm__'
})Applying that patch to v3.8.4 and re-running the cases above:
key ORDER drift -> {} # order alone means nothing to the server; send nothing
order + a real change -> {"a":99} # the genuine change still goes through
key REMOVED -> {"b":"__rm__"} # valid path, no leading dot
normal change -> {"a":9} # unchanged
nested key-order -> {"f":{...}} # unchanged; #10001's behaviour is preserved below the rootHappy to open a PR against 3.x with this plus tests if you'd like — just say which shape you prefer for the root case (per-property diffs as above, or skipping the root key-order check entirely, since property order alone carries no meaning for the server).
A defensive server-side check would also help surface this class of bug: updateProperty() could reject an empty property name with a clearer message than [$], which is what made this hard to diagnose.
Source: livewire/livewire