Skip to content

Commit 9e4a7eb

Browse files
authored
fix(session): age flash values at the start of the request (#2198)
1 parent 8838c1e commit 9e4a7eb

7 files changed

Lines changed: 130 additions & 9 deletions

File tree

packages/http/src/Session/ManageSessionMiddleware.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ public function __construct(
2424

2525
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
2626
{
27+
// Flash values are aged at the start of the request, before the response
28+
// body is rendered, so a value flashed on the previous request stays
29+
// readable this request and is expired on the next one.
30+
$this->session->cleanup();
31+
2732
try {
2833
return $next($request);
2934
} finally {
30-
$this->session->cleanup();
3135
$this->sessionManager->save($this->session);
3236

3337
match ($this->config->cleanupStrategy) {

packages/http/src/Session/Session.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,9 @@ public function reflash(): void
6565
*/
6666
public function get(string|UnitEnum $key, mixed $default = null): mixed
6767
{
68-
$key = Str\parse($key);
69-
$value = $this->data[$key] ?? $default;
68+
$value = $this->data[Str\parse($key)] ?? $default;
7069

7170
if ($value instanceof FlashValue) {
72-
$this->expiredKeys[$key] = $key;
7371
$value = $value->value;
7472
}
7573

@@ -110,13 +108,28 @@ public function remove(string|UnitEnum $key): void
110108
}
111109

112110
/**
113-
* Cleans up expired session values.
111+
* Expires flash values by one request. Values flashed on the previous request
112+
* are removed; values flashed since are marked to expire on the next request.
113+
*
114+
* This runs once, at the start of the request, so that flash values remain
115+
* readable while the request is handled - including while the response body
116+
* (e.g. a view) is being rendered - regardless of whether they are read.
114117
*/
115118
public function cleanup(): void
116119
{
117120
foreach ($this->expiredKeys as $key) {
118121
$this->remove($key);
119122
}
123+
124+
$this->expiredKeys = [];
125+
126+
foreach ($this->data as $key => $value) {
127+
if (! $value instanceof FlashValue) {
128+
continue;
129+
}
130+
131+
$this->expiredKeys[$key] = $key;
132+
}
120133
}
121134

122135
/**
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Controllers;
6+
7+
use Tempest\Http\Responses\Redirect;
8+
use Tempest\Http\Session\Session;
9+
use Tempest\Router\Get;
10+
use Tempest\Router\Post;
11+
use Tempest\View\View;
12+
13+
use function Tempest\View\view;
14+
15+
final readonly class FlashViewController
16+
{
17+
public function __construct(
18+
private Session $session,
19+
) {}
20+
21+
#[Post('/flash-view')]
22+
public function flash(): Redirect
23+
{
24+
$this->session->flash('message', 'flashed-message');
25+
26+
return new Redirect('/flash-view');
27+
}
28+
29+
#[Get('/flash-view')]
30+
public function show(): View
31+
{
32+
return view('./flash-view.view.php');
33+
}
34+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
use Tempest\Http\Session\Session;
4+
5+
use function Tempest\Container\get;
6+
7+
// The flash value is consumed while the view renders, i.e. after the session
8+
// middleware has run. It must still expire for the following request.
9+
$message = get(Session::class)->get('message');
10+
?>
11+
12+
<div id="flash">{{ $message }}</div>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Http;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
9+
10+
/**
11+
* @internal
12+
*/
13+
final class FlashInViewTest extends FrameworkIntegrationTestCase
14+
{
15+
#[Test]
16+
public function flash_value_consumed_while_rendering_a_view_is_shown_once(): void
17+
{
18+
// A request flashes a value and redirects.
19+
$this->http
20+
->post('/flash-view')
21+
->assertRedirect('/flash-view');
22+
23+
// The redirected request reads the flash value while rendering the view.
24+
$this->http
25+
->get('/flash-view')
26+
->assertOk()
27+
->assertSee('flashed-message');
28+
29+
// The following request no longer sees it: it was aged out at the start of
30+
// this request, even though it was only ever read while rendering the view.
31+
$this->http
32+
->get('/flash-view')
33+
->assertOk()
34+
->assertNotSee('flashed-message');
35+
}
36+
}

tests/Integration/Http/FormSessionTest.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,12 @@ public function errors_are_flashed_and_cleared_after_next_request(): void
166166
// First access - errors exist
167167
$this->assertTrue($this->formSession->hasErrors());
168168

169-
// Simulate cleanup after request
169+
// Start of the next request: still available to display.
170170
$this->session->cleanup();
171+
$this->assertTrue($this->formSession->hasErrors());
171172

172-
// Second access - errors cleared
173+
// Start of the request after that: cleared.
174+
$this->session->cleanup();
173175
$this->assertFalse($this->formSession->hasErrors());
174176
}
175177

@@ -183,10 +185,12 @@ public function values_are_flashed_and_cleared_after_next_request(): void
183185
// First access - values exist
184186
$this->assertEquals('John Doe', $this->formSession->getOriginalValueFor('name'));
185187

186-
// Simulate cleanup after request
188+
// Start of the next request: still available to display.
187189
$this->session->cleanup();
190+
$this->assertEquals('John Doe', $this->formSession->getOriginalValueFor('name'));
188191

189-
// Second access - values cleared
192+
// Start of the request after that: cleared.
193+
$this->session->cleanup();
190194
$this->assertEquals('', $this->formSession->getOriginalValueFor('name'));
191195
}
192196
}

tests/Integration/Http/SessionTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,25 @@ public function flash(): void
7474
$this->session->flash('message', 'success');
7575
$this->assertEquals('success', $this->session->get('message'));
7676

77+
// Start of the next request: the value is still available (e.g. to render).
7778
$this->session->cleanup();
79+
$this->assertEquals('success', $this->session->get('message'));
80+
81+
// Start of the request after that: it has expired.
82+
$this->session->cleanup();
83+
$this->assertNull($this->session->get('message'));
84+
}
85+
86+
#[Test]
87+
public function flash_value_expires_even_when_never_read(): void
88+
{
89+
$this->session->flash('message', 'success');
90+
91+
// Aging is independent of reads: two request boundaries expire the value
92+
// without it ever being retrieved.
93+
$this->session->cleanup();
94+
$this->session->cleanup();
95+
7896
$this->assertNull($this->session->get('message'));
7997
}
8098

0 commit comments

Comments
 (0)