Livewire: Bound data is converted to array after render

Created on 2 May 2019  路  28Comments  路  Source: livewire/livewire

Describe the bug
Livewire seems to convert bound data (collections, objects, Eloquent instances) into arrays after a render.

To Reproduce
1) Create a livewire component and add a property public $user;.
2) On mount: fetch a user and optionally log its class.
3) Add an action that, when a button gets clicked, tries to fetch a related model via $this->user->relatedModel.

Expected behavior
An instance of the related model is fetched.

Actual behavior
An exception is thrown: Call to a member function relatedModel() on array.

Screenshots

image

Most helpful comment

Ok all, I just tagged v0.3.0 with this change in it.

You can now set eloquent models (and really anything you want) as protected properties, and they will be "dehydrated" into the app's cache on the backend.

Public properties are now officially restricted to arrays, null, numerics, and strings. An error will be thrown otherwise.

I still have to document all this, but I wanted to get some opinions on it here and have it tested out a little.

The cache that is stored for each component is garbage collected, so there shouldn't be caches growing to infinity, but all of this needs to be used in the wild to really know.

Also, you can access the cache using the following new ->cache() component method:

// Will get "key" from the cache
$this->cache('key')
// Will set "key" to "value" in the cache
$this->cache('key', 'value')
// Will get "key" from the cache with a default of "value"
$this->cache()->get('key', 'value')
// Will set "key" to value in the cache
$this->cache()->put('key', 'value')

If you want more info, check out the release notes: https://github.com/calebporzio/livewire/releases/tag/v0.3.0

All 28 comments

I have also encountered this. Not looked into why yet

I have also encountered this. Not looked into why yet

Same here. 馃檶I think it's because the component is receiving the previous data from the message every time and since it's been converted to an array in the previous response from Livewire, it doesn't know it should be an App\User instance.

Maybe it would be better to serialize the state, encrypt it and transport it such way?

Yeah, so this is a tricky one. Livewire used to support saving models and such to properties. It would dehydrate/serialize them, and re-hydrate/serialize them on the way back.

The problem: Javascript has to be able to access all persisted state inside Livewire components, so serializing models would hide data from Javascript.

For example:

<input wire:model="user.name">

would not work, because Javascript would try to access a "name" property on the "user" object.

In the olden days, Livewire used to support this behavior, because it forced you to write the previous "input" tag like so:

<input wire:model="user.name" value="{{ $user->name }}">

But I believe the benefits of having Javascript aware of properties outway the benefit of persisting models.

I could be wrong, so let's talk about it.

Also, another reason is, passing serialized models back and forth on every request is expensive.

My dumb solution is to include some secret key such as '_eloquent_class="App\Model"'. If Livewire sees that, then it instantiates the model and hydrates it. Possibly even just using the id to pull a fresh instance.

Not a super good solution because it would only work for Eloquent models. But I'd like to think that would cover the majority of use cases for the time being.

It also causes an issue when you have, for example, a property 'users' that contains a collection of User eloquent models. All those objects become arrays after you execute an action. This throws an error in blade, because you access the data as objects instead of arrays

To avoid this issue at the moment, you must update the property data every time the component is updated (via the 'updated' lifecycle hook method). Or you have to handle the 'users' property as an array of arrays instead of a collection of objects

This made my tests passes but throws a bunch of errors when I load my application on browser

If this is the case, what's the ideal way to build views? Since most of our views take in a model and use object notion to access properties, we'd need to rewrite most of our views to use array notation (which doesn't seem like the best practice). It also eliminates the possibility of using any functions available on the model in the view. Perhaps for the first issue, arrays can be cast to an object before being injected into the view.

IMHO, as a dev who enjoys the benefits of JS but hate writing the code, I believe working with the PHP models themselves is much more valuable than having JS aware of properties. It's the primary reason why I use this package: so I don't have to write JS, and rewrite JS function that already exist in my PHP classes.

But then again, I'm using the package, not developing it, so don't get me wrong, I do get your point :)

Just my two cents, thanks for all your work!

So, I've been kind of silent on this issue because I'm working on a solution.

Until I have something ready for the framework, here are two recommended strategies.

Also, you are all right for wanting this and thinking that's how it should work. I agree with you. The current situation is not ideal.

Here are 3 ways you can currently handle this problem in the mean time:

Strategy A

class ShowPost extends Component
{
    public $postId;

    public function mount($postId)
    {
        $this->postId = $postId;
    }

    public function render()
    {
        return view('livewire.show-post', [
            'post' => Post::find($this->postId);
        ]);
    }
}

Strategy B

class ShowPost extends Component
{
    public $postId;
    protected $post;

    public function mount($postId)
    {
        $this->postId = $postId;
        $this->post = Post::find($postId);
    }

    public function hydrate()
    {
        $this->post = Post::find($postId);
    }

    public function someAction()
    {
        $this->post->update([...]);
    }

    public function render()
    {
        return view('livewire.show-post', [
            'post' => $this->post,
        ]);
    }
}

Strategy C

class ShowPost extends Component
{
    protected $post;

    public function mount($postId)
    {
        session()->put($this->id.'post', $this->post = Post::find($postId););
    }

    public function hydrate()
    {
        $this->post = session()->get($this->id.'post');
    }

    public function render()
    {
        return view('livewire.show-post', [
            'post' => $this->post,
        ]);
    }
}

@calebporzio I'm wondering, in Livewire\Component::output($errors = null), is there any way to get the component arguments passed in? Or, if this solution even holds water considering the rest of the architecture. Curious about your thoughts on this solution:

public function output($parameters, $errors = null)
{
    // Remount the component with parameters before rendering the view
    $this->mount($parameters);

    $view = $this->render();

    throw_unless($view instanceof View,
        new \Exception('"render" method on [' . get_class($this) . '] must return instance of [' . View::class . ']'));

    $dom = $view
        ->with([
            'errors' => (new ViewErrorBag)->put('default', $errors ?: new MessageBag),
            '_instance' => $this,
        ])
        // Automatically inject all public properties into the blade view.
        ->with($this->getPublicPropertiesDefinedBySubClass())
        ->render();

    // Basic minification: strip newlines and return carraiges.
    return str_replace(["\n", "\r"], '', $dom);
}

I have gotten it to work with hard-coded values, but haven't done any additional testing/benchmarking/etc.

Hi @pddevins, can you be more explicit about what passing in the parameters to ->output() would solve? Thanks!

Hey @calebporzio. Totally!
The Issue
The issue with rendering models after initial page-load is that it is converted into an array of it's attributes when sent to /livewire/message. When the Component is re-hydrated, it assigns any model property as an array, unless the Component is remounted with it's original parameters.
Proposed Solution
It seems that Component::output is the crux of hydrating the component and rendering the view from its properties. If we can somehow re-mount the component in this method, it would eliminate the issue where models are converted into arrays, because any model instances instantiated in the component's mount method would override the model-as-an-array hydration.

Does that make sense?

Thanks for the clarification.

I just tagged a new release with some big changes to this part of the system: v0.2.5

Now, if you try to set models or model collections as public properties an error will be thrown. Also, if you set collect() or anything else, they will be cast to their primitive form before hitting the view.

This will be frustrating for people, but at least it will be clear that Livewire does not support Eloquent models between requests.

I'd love it if someone pulled it down, tried this and let me know what they think.

Thanks everyone.

Hey @calebporzio!

Is this a temporary thing to make it more obvious that we cannot set eloquent model as property while working on the solution? Or is this the solution?

So, I've been kind of silent on this issue because I'm working on a solution.

Until I have something ready for the framework, here are two recommended strategies.

If it's a temporary thing, then I think it's great because as you said, now we'll know right away what to do/fix.

If it's not temporary, is the reason behind this strictly for performance issues?

I feel like, as a user of the package, we would still lose lots of opportunities by not having access to our initial Model / Collection in the view (class attributes based on a condition on the model, shorter syntax on collection objects, etc.)

Thanks

Great question @larryx64, you're right - this is only temporary so the shortcomings are obvious. I am working on a solution to be able to set protected properties to eloquent models and collections and they will be secure.

Ciao @calebporzio,

do you have any news about this problem?

Thank you for everything :)

Haha, I wish. It's kind of: as soon as I get time. Hoping to tackle it fully next week. It's the last big piece of the Livewire puzzle and I want to nail it.

Hoping to tackle it fully next week.

It sounds great! 馃憤

It's the last big piece of the Livewire puzzle and I want to nail it.

Yes, Livewire is amazing and this is the icing on the cake.
Thanks for your work and keep rocking!

Ok all, I just tagged v0.3.0 with this change in it.

You can now set eloquent models (and really anything you want) as protected properties, and they will be "dehydrated" into the app's cache on the backend.

Public properties are now officially restricted to arrays, null, numerics, and strings. An error will be thrown otherwise.

I still have to document all this, but I wanted to get some opinions on it here and have it tested out a little.

The cache that is stored for each component is garbage collected, so there shouldn't be caches growing to infinity, but all of this needs to be used in the wild to really know.

Also, you can access the cache using the following new ->cache() component method:

// Will get "key" from the cache
$this->cache('key')
// Will set "key" to "value" in the cache
$this->cache('key', 'value')
// Will get "key" from the cache with a default of "value"
$this->cache()->get('key', 'value')
// Will set "key" to value in the cache
$this->cache()->put('key', 'value')

If you want more info, check out the release notes: https://github.com/calebporzio/livewire/releases/tag/v0.3.0

Very very cool! What happens if a cache key is garbage collected and then live wire can鈥檛 get to it? Is it still possible to rehydrate the collection or model?

@calebporzio This looks great! Is there any reason why boolean is not supported as a public property?

oops, good catch @intellow. Just tagged v0.3.1 that supports booleans.

@paulcsmith - a cache key shouldn't get garbage collected if the component is still in use by the browser.

Each Livewire component has a unique id, (that you can access using $this->id). The id is what the cache key is for that component. When it is either removed from the DOM or if you navigate away from a page, that id is set to be garbage collected. On the next Livewire ajax request, that id will be garbage collected.

Does that answer your question?

How does it know that you've navigated away from the page as opposed to just leaving the page open for a few hours without touching it?

@calebporzio Thank you so much for the answer. Yes that makes sense. Along with the question by @intrepidws I had one more. What if you redeploy the app and the cache is in memory? I am new to Laravel so maybe I'm misunderstanding the cache and it is actually persisted to Redis or the file system in which case this would be a non-issue, but thought I'd ask just in case

@intrepidws - I hook into the "beforeunload" browser event and throw the ids into localStorage

https://github.com/calebporzio/livewire/blob/3d3e74312bd39097f99c7072bc641af74bd24be0/src/js/index.js#L73

@paulcsmith, Laravel uses the "file" cache driver by default. It stores cached data in files inside storage/framework/cache/data (I'm pretty sure).

So yes, non-issue for deployments. A redis or database cache is definitely more scalable, so I'll be keeping an eye on this and may include a recommendation to use one of those drivers in the docs.

Good question

Was this page helpful?
0 / 5 - 0 ratings