hotwired-laravel/turbo-laravel

Lazy-load url clashes with Laravel back() and previous()

SylarRuby opened this issue ยท 15 comments

Think I've found a bug-ish? Say you have a lazy-loading section:

<turbo-frame id="my_frame" :src="route('my.page')">
    <p>Loading...</p>
</turbo-frame>

After page loads, content is then fetched GET then replace/update my_frame. Fine. I have a back button defined with

<a href="{{ url()->previous() }}">Back</a>

When I click the back link, it goes to route('my.page')'s url. The behaviour is correct, in some sense. How to avoid this? By not using url()->previous()?

EDIT:
This seems to relate to this.

Going to close this for now. I've restarted my app using url()->back() instead of url()->previous() and all seems well.

Hey, @SylarRuby

I'll test this out during lunch today.

So, I have a root page / that renders a simple Blade view:

@extends('layouts.main')

@section('content')

<h1>Main Page</h1>

<x-turbo-frame id="my-frame" src="{{ url('/my-page') }}">
    <p>Loading...</p>
</x-turbo-frame>

@stop

The /my-page route also renders a simple Blade view:

@extends('layouts.main')

@section('content')

<h1>My Page</h1>

<x-turbo-frame id="my-frame">
    <div>
        <p>This content was loaded from /my-page.</p>

        <a href="{{ url()->previous() }}">Back</a>
    </div>
</x-turbo-frame>

@stop

When the root page loads, the lazy-loading Turbo Frame will make a GET /my-page request and replace the #my-frame Turbo Frame's contents with the one in the /my-page, which contains a link that uses {{ url()->previous() }}. The link points to / in this case - which is the page that makes the request to the /my-page one.

This is the expected behavior, right?

\cc @SylarRuby and @odion-cloud

In my case:

@extends('layouts.main')

@section('content')

<a href="{{ url()->previous() }}">Back</a>

<h1>Main Page</h1>

<x-turbo-frame id="my-frame" src="{{ url('/my-page') }}">
    <p>Loading...</p>
</x-turbo-frame>

@stop

After page loads, with updated content, pressing the back button goes to /my-page instead of /. What does work:

@extends('layouts.main')

@section('content')

<a href="{{ redirect()->back()->getTargetUrl() }}">Back</a>

<h1>Main Page</h1>

<p>This content was loaded from /my-page.</p>

@stop

That link is outside the Turbo Frame, rendered in the first-page render. There is no "previous" URL if it was the first page render. So it all checks out.

Laravel keeps track of your most recently visited page in session (every time you make a GET request to the app - see source). The url()->previous() tries to use the referer header, which should be present when you click on a link to the page, for example. Otherwise, it defaults to using the session one via session()->previousUrl() (see source), which is what is happening in your case.

So that may be where the confusion lies.

It seems like expected behavior, but I'll see what I can do.

I was able to solve this issue at the application level. To do so, you need to create a new StartSession middleware:

php artisan make:middleware StartSession

Next, add the following content to that middleware:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Session\Middleware\StartSession as Middleware;

class StartSession extends Middleware
{
    /**
     * Store the current URL for the request if necessary.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Illuminate\Contracts\Session\Session  $session
     * @return void
     */
    protected function storeCurrentUrl(Request $request, $session)
    {
        if ($request->isMethod('GET') &&
            $request->route() instanceof Route &&
            ! $request->ajax() &&
            ! $request->prefetch() &&
            ! $request->isPrecognitive() &&
            ! $request->headers->has('Turbo-Frame')) {
            $session->setPreviousUrl($request->fullUrl());
        }
    }
}

Note: the key part here is the ! $request->headers->has('Turbo-Frame') condition added to the check there, which means Turbo Frame requests won't affect the previous URL.

Now, you need to swap the built-in middleware with the new one in the app/Http/Kernel.php file (should be in the "web" middleware group:

- \Illuminate\Session\Middleware\StartSession::class,
+ \App\Http\Middleware\StartSession::class,

That should do the trick.

Here's a test if you want to add it to your application
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use Illuminate\Support\Facades\Route;

class StartSessionTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        Route::middleware('web')->get('_test/session/home', function () {
            return url()->previous();
        });

        Route::middleware('web')->get('_test/session/my-page', function () {
            return 'my-page-content';
        });
    }

    /** @test */
    public function it_stores_previous_url_in_session()
    {
        $this->get('_test/session/my-page')
            ->assertOk()
            ->assertSee('my-page-content');

        $this->get('_test/session/home')
            ->assertOk()
            ->assertSee(url('_test/session/my-page'));
    }

    
    /** @test */
    public function turbo_frames_dont_record_previous_url()
    {
        $this->withHeaders(['Turbo-Frame' => 'testing-frame'])
            ->get('_test/session/my-page')
            ->assertOk()
            ->assertSee('my-page-content');

        $this->get('_test/session/home')
            ->assertOk()
            ->assertSee(url(''))
            ->assertDontSee(url('_test/session/my-page'));
    }
}

I'm gonna close the issue for now. Not sure if this should be pushed upstream to Laravel yet.

\cc @SylarRuby and @odion-cloud

Works great. And thanks for the test ๐ŸŽ‰ ๐Ÿš€

Hi @tonysm, one little question. I'm on a homepage (/). I change the browser url, without refreshing using js:

// or window.history.pushState(..)
window.history.replaceState(null, null, "/?foo=2");

Once url is changed, while still on homepage, I navigate to /books/1. On the show page, I hit the back button:

<a href="{{ redirect()->back() }}">Back</a>

If this is a turbo question, how to go back to /?foo=2? Pressing the browser back button, only the url changes to /?foo=2 while still on the show page. I guess two issues? If this is a SO question, please confirm.

Thanks

What's the reason for changing the URL like that instead of making a Turbo visit? I'd say try using url()->previous() instead of url()->back()

Good question. We have an options tab than when something is selected, turbo stream is used to replace the table contents:

Screenshot 2022-05-31 at 13 22 27

We also want the url to update to match the selected items as url params. I had to save the new url in localStoarge then use Turbo.visit to go back:

import * as Turbo from "@hotwired/turbo";

[..]

const prevUrl = localStorage.getItem('prevUrl')
Turbo.visit(prevUrl, {action: "restore"})

Works great. Turbo is the solution here ๐Ÿ˜‰

@SylarRuby btw, that sounds like something Turbo Frames can help. I recorded a screencast the other day of something similar. The idea is that you would wrap the table with a <turbo-frame data-turbo-action="advance" target="_top" id="my-table"> (docs). I guess above the table you have a form, so that would be outside of the frame but pointing to it like: <form data-turbo-frame="my-table"> (docs) which would trigger the frame to update it, then the data-turbo-action="advance" would update the URL accordingly.

Oh wow! Yes, sure, I'll have a look. Thanks

@tonysm All working. Needed to restart my server.

Many thanks!

@tonysm Heads up, the base storeCurrentUrl method has changed. There's a new ! $request->isPrecognitive() check added.

Thanks, @KostasKostogloy. I've updated the comment.