From 0607705d70be02c2340a8071220c22006cc0dbc2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:07:00 +0200 Subject: [PATCH] fix(teams): keep switched team when concurrent requests save the session Closes #11733 --- .ai/lessons.md | 3 + app/Http/Kernel.php | 2 + .../Middleware/KeepConcurrentTeamSwitch.php | 37 +++++++++ .../TeamSwitchConcurrentRequestTest.php | 76 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 app/Http/Middleware/KeepConcurrentTeamSwitch.php create mode 100644 tests/Feature/TeamSwitchConcurrentRequestTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 22b9b3295e..a28d452c5c 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -110,3 +110,6 @@ ## Keep browser form restore away from app state - Firefox session restore writes saved input values back and fires a trusted `input` event. Add `autocomplete="off"` to inputs that only mirror client state (for example an Alpine-bound color picker), or their handlers run without a user action. + +## Session writes are last-writer-wins +- Laravel saves the whole session payload at the end of every request, and pages run concurrent `wire:poll` requests. A value that one request changes can be overwritten by a request that started earlier. `KeepConcurrentTeamSwitch` protects `currentTeam`; protect any new session value that changes mid-session the same way, or keep it out of the session. diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index bfb98ede79..a42af07c27 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -15,6 +15,7 @@ use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EnsureMcpEnabled; use App\Http\Middleware\EnsureTeamMcpEnabled; use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; +use App\Http\Middleware\KeepConcurrentTeamSwitch; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RequirePassword; @@ -72,6 +73,7 @@ class Kernel extends HttpKernel EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, + KeepConcurrentTeamSwitch::class, ShareErrorsFromSession::class, VerifyCsrfToken::class, SubstituteBindings::class, diff --git a/app/Http/Middleware/KeepConcurrentTeamSwitch.php b/app/Http/Middleware/KeepConcurrentTeamSwitch.php new file mode 100644 index 0000000000..7f9a796ade --- /dev/null +++ b/app/Http/Middleware/KeepConcurrentTeamSwitch.php @@ -0,0 +1,37 @@ +session()->get('currentTeam'), 'id'); + + $response = $next($request); + + $session = $request->session(); + if (data_get($session->get('currentTeam'), 'id') !== $teamIdAtStart) { + return $response; + } + + $storedSession = clone $session; + $storedSession->start(); + $storedTeam = $storedSession->get('currentTeam'); + if ($storedTeam && data_get($storedTeam, 'id') !== $teamIdAtStart) { + $session->put('currentTeam', $storedTeam); + } + + return $response; + } +} diff --git a/tests/Feature/TeamSwitchConcurrentRequestTest.php b/tests/Feature/TeamSwitchConcurrentRequestTest.php new file mode 100644 index 0000000000..0de09bbd00 --- /dev/null +++ b/tests/Feature/TeamSwitchConcurrentRequestTest.php @@ -0,0 +1,76 @@ + $action) { + $routes->add(Route::middleware(['web', 'auth'])->get($uri, $action)); + } + foreach (app('router')->getRoutes() as $route) { + $routes->add($route); + } + app('router')->setRoutes($routes); +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + + $this->user = User::factory()->create(); + $this->firstTeam = $this->user->teams()->first(); + $this->secondTeam = Team::factory()->create(); + $this->secondTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->thirdTeam = Team::factory()->create(); + $this->thirdTeam->members()->attach($this->user->id, ['role' => 'owner']); + Team::query()->update(['show_boarding' => false]); + $this->user->forceFill(['current_team_id' => $this->firstTeam->id])->saveQuietly(); + + registerTeamSwitchTestRoutesBeforeCatchAll([ + '_test/team-switch-during-request' => function () { + // The team switcher saves the session while this request still runs. + $switchRequestSession = clone session()->driver(); + $switchRequestSession->start(); + $switchRequestSession->put('currentTeam', $this->secondTeam); + $switchRequestSession->save(); + $this->user->forceFill(['current_team_id' => $this->secondTeam->id])->saveQuietly(); + + return 'ok'; + }, + '_test/switch-to-second-team' => function () { + $otherRequestSession = clone session()->driver(); + $otherRequestSession->start(); + $otherRequestSession->put('currentTeam', $this->thirdTeam); + $otherRequestSession->save(); + + refreshSession($this->secondTeam); + + return 'ok'; + }, + '_test/current-team' => fn () => 'team:'.currentTeam()->id, + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->firstTeam]); +}); + +test('a request that was in flight during a team switch does not restore the previous team', function () { + $this->get('/_test/team-switch-during-request')->assertOk(); + + $this->get('/_test/current-team')->assertOk()->assertSee('team:'.$this->secondTeam->id); + expect($this->user->fresh()->current_team_id)->toBe($this->secondTeam->id); +}); + +test('a request that switches the team keeps the team it selected over a concurrent change', function () { + $this->get('/_test/switch-to-second-team')->assertOk(); + + $this->get('/_test/current-team')->assertOk()->assertSee('team:'.$this->secondTeam->id); + expect($this->user->fresh()->current_team_id)->toBe($this->secondTeam->id); +});