livewire / livewire/livewire

[3.x] diff() emits invalid root update paths ('' and '.key') → "Public property [$] not found", wedging components after a deploy

Open
#10,535 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PHP
Stars
23.6k
Forks
1.7k
Avg merge
15h 29m
Merged PRs (30d)
89

Description

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() on main is still unguarded in both branches, even though diffRecursive() 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.length precondition from diff() on main ("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:

  1. Component with public $a = 1; public $c = 3; and any wire:model input.
  2. Load the page and leave it open.
  3. Add public $b = 2; between the two existing properties, so the snapshot order changes.
  4. 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 root

Happy 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in js/utils.js with diff() and compare its root key-order and removal-path handling with the guarded diffRecursive() behavior described in the issue. Run the provided repro.mjs cases against the released source, then add focused coverage showing that root and removal updates never produce empty or leading-dot paths while ordinary and nested changes remain valid.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, laravel, php
Domain
backend, frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.