Skip to content

Commit e9348b2

Browse files
authored
fix: SPA mode resource create page back button cache (#20297)
* fix: SPA mode resource create page back button cache * simplify
1 parent b7eeae7 commit e9348b2

8 files changed

Lines changed: 140 additions & 4 deletions

File tree

packages/panels/dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/panels/resources/js/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import sidebar from './stores/sidebar.js'
33
import './dark-mode.js'
44
import './error-notifications.js'
55
import './history-state.js'
6+
import './reset-restored-resource-create-record-pages.js'
67
import './scroll-sidebar.js'
78
import './unsaved-changes-alert.js'
89

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
document.addEventListener('livewire:init', () => {
2+
// In SPA mode, navigating back to a create page restores it from Livewire's
3+
// history cache, including an `isCreating` state of `true` from before the
4+
// post-creation redirect, which blocks the form from being submitted again.
5+
// A fresh page load always renders `isCreating` as `false`, so a create
6+
// page component can only initialize with `isCreating` as `true` if it was
7+
// restored from the cache. Outside of SPA mode, this cannot happen, since
8+
// Livewire's `DisableBackButtonCacheMiddleware` sends a `no-store` header
9+
// that excludes its pages from the browser's back/forward cache. Assigning
10+
// to `$wire` only mutates the client-side state, which is synced with the
11+
// server during the next request, such as the next creation attempt.
12+
window.Livewire.hook('component.init', ({ component }) => {
13+
if (
14+
!component.el?.classList?.contains('fi-resource-create-record-page')
15+
) {
16+
return
17+
}
18+
19+
if (component.snapshot?.data?.isCreating !== true) {
20+
return
21+
}
22+
23+
component.$wire.isCreating = false
24+
})
25+
})

packages/panels/src/Resources/Pages/CreateRecord.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
use Illuminate\Database\Eloquent\Model;
2323
use Illuminate\Support\Facades\Event;
2424
use Illuminate\Support\Js;
25-
use Livewire\Attributes\Locked;
2625
use Throwable;
2726

2827
/**
@@ -47,7 +46,14 @@ class CreateRecord extends Page
4746

4847
protected static bool $canCreateAnother = true;
4948

50-
#[Locked]
49+
/**
50+
* After a successful creation, this stays `true` while the user is redirected, to prevent duplicate
51+
* records from additional clicks of the submit button. In SPA mode, if the user then navigates back,
52+
* the page is restored from Livewire's history cache with this still `true`, which would block the
53+
* form from ever being submitted again. JavaScript detects the restoration and resets this property
54+
* in Livewire's client-side state, which is synced with the server during the next request. It is
55+
* deliberately not `#[Locked]`, since that would prevent the client-side reset from being synced.
56+
*/
5157
public bool $isCreating = false;
5258

5359
public function getBreadcrumb(): string

tests/src/Fixtures/Models/User.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ protected function casts(): array
5555

5656
public function canAccessPanel(Panel $panel): bool
5757
{
58-
return in_array($panel->getId(), ['admin', 'slugs', 'app-authentication', 'email-authentication', 'required-multi-factor-authentication']);
58+
return in_array($panel->getId(), ['admin', 'slugs', 'spa', 'app-authentication', 'email-authentication', 'required-multi-factor-authentication']);
5959
}
6060

6161
public function posts(): HasMany
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Filament\Tests\Fixtures\Providers;
4+
5+
use Filament\Http\Middleware\Authenticate;
6+
use Filament\Http\Middleware\AuthenticateSession;
7+
use Filament\Http\Middleware\DisableBladeIconComponents;
8+
use Filament\Http\Middleware\DispatchServingFilamentEvent;
9+
use Filament\Panel;
10+
use Filament\PanelProvider;
11+
use Filament\Tests\Fixtures\Resources\Tickets\TicketResource;
12+
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
13+
use Illuminate\Cookie\Middleware\EncryptCookies;
14+
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
15+
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
16+
use Illuminate\Routing\Middleware\SubstituteBindings;
17+
use Illuminate\Session\Middleware\StartSession;
18+
use Illuminate\View\Middleware\ShareErrorsFromSession;
19+
20+
class SpaPanelProvider extends PanelProvider
21+
{
22+
public function panel(Panel $panel): Panel
23+
{
24+
return $panel
25+
->id('spa')
26+
->path('spa')
27+
->login()
28+
->spa()
29+
->resources([
30+
TicketResource::class,
31+
])
32+
->middleware([
33+
EncryptCookies::class,
34+
AddQueuedCookiesToResponse::class,
35+
StartSession::class,
36+
AuthenticateSession::class,
37+
ShareErrorsFromSession::class,
38+
class_exists(PreventRequestForgery::class) ? PreventRequestForgery::class : VerifyCsrfToken::class,
39+
SubstituteBindings::class,
40+
DisableBladeIconComponents::class,
41+
DispatchServingFilamentEvent::class,
42+
])
43+
->authMiddleware([
44+
Authenticate::class,
45+
]);
46+
}
47+
}

tests/src/Panels/Resources/Pages/CreateRecordTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use Filament\Resources\Events\RecordCreated;
66
use Filament\Resources\Events\RecordSaved;
77
use Filament\Tests\Fixtures\Models\Post;
8+
use Filament\Tests\Fixtures\Models\Ticket;
9+
use Filament\Tests\Fixtures\Models\User;
810
use Filament\Tests\Fixtures\Policies\TicketPolicy;
911
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingDataPost;
1012
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingRepeaterPost;
@@ -16,6 +18,7 @@
1618
use Filament\Tests\Fixtures\Resources\Tickets\TicketResource;
1719
use Filament\Tests\Panels\Resources\TestCase;
1820
use Illuminate\Auth\Access\Response;
21+
use Illuminate\Support\Facades\Artisan;
1922
use Illuminate\Support\Facades\Event;
2023

2124
use function Filament\Tests\livewire;
@@ -409,6 +412,58 @@
409412
app()->bind(TicketPolicy::class . '::create', fn (): bool => true);
410413
});
411414

415+
it('blocks duplicate creation attempts while `$isCreating` after a successful creation', function (): void {
416+
$component = livewire(CreateTicket::class)
417+
->call('create')
418+
->assertRedirect();
419+
420+
expect(Ticket::count())->toBe(1);
421+
422+
$component->call('create');
423+
424+
expect(Ticket::count())->toBe(1);
425+
});
426+
427+
it('can reset `$isCreating` from the client to release the duplicate creation guard when the page is restored from the browser history cache', function (): void {
428+
$component = livewire(CreateTicket::class)
429+
->call('create')
430+
->assertRedirect();
431+
432+
expect(Ticket::count())->toBe(1);
433+
434+
$component
435+
->set('isCreating', false)
436+
->call('create')
437+
->assertRedirect();
438+
439+
expect(Ticket::count())->toBe(2);
440+
});
441+
442+
it('can create a record again in the browser after navigating back to a create page restored from the history cache', function (): void {
443+
retry(10, function (): void {
444+
Artisan::call('filament:assets');
445+
446+
Ticket::query()->delete();
447+
448+
$this->actingAs(User::factory()->create());
449+
450+
visit(TicketResource::getUrl('create', panel: 'spa'))
451+
->assertSee('Create Ticket')
452+
->click('.fi-sc-form button[type="submit"]')
453+
->waitForText('View Ticket')
454+
->assertPathIs('/spa/tickets/*')
455+
->back()
456+
->waitForText('Create Ticket')
457+
->assertPathIs('/spa/tickets/create')
458+
->wait(1)
459+
->click('.fi-sc-form button[type="submit"]')
460+
->waitForText('View Ticket')
461+
->assertPathIs('/spa/tickets/*');
462+
463+
expect(Ticket::count())->toBe(2);
464+
});
465+
});
466+
412467
it('re-authorizes viewAny on Livewire updates after the initial mount of a create page', function (): void {
413468
app()->bind(TicketPolicy::class . '::viewAny', fn (): bool => true);
414469

tests/src/TestCase.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use Filament\Tests\Fixtures\Providers\RequiredMultiFactorAuthenticationPanelProvider;
3131
use Filament\Tests\Fixtures\Providers\SlugsPanelProvider;
3232
use Filament\Tests\Fixtures\Providers\SlugTenancyPanelProvider;
33+
use Filament\Tests\Fixtures\Providers\SpaPanelProvider;
3334
use Filament\Tests\Fixtures\Providers\TenancyPanelProvider;
3435
use Filament\Tests\Fixtures\Providers\TenantMenuFlatPanelProvider;
3536
use Filament\Tests\Fixtures\Providers\TenantMenuGroupingPanelProvider;
@@ -88,6 +89,7 @@ protected function getPackageProviders($app): array
8889
SingleDomainPanel::class,
8990
SlugsPanelProvider::class,
9091
SlugTenancyPanelProvider::class,
92+
SpaPanelProvider::class,
9193
TenancyPanelProvider::class,
9294
TenantMenuFlatPanelProvider::class,
9395
TenantMenuGroupingPanelProvider::class,

0 commit comments

Comments
 (0)