1
0

fix(flash): make flash messages one-shot per request

This commit is contained in:
2026-04-15 18:45:45 +02:00
parent 63be2def76
commit 57b7920098
7 changed files with 149 additions and 20 deletions

View File

@@ -8,7 +8,6 @@ use MintyPHP\Session;
class Flash
{
private const SESSION_KEY = 'flash_messages';
private const KEEP_KEY = 'flash_keep';
private static function ensureSession()
{
@@ -74,10 +73,50 @@ class Flash
}));
}
public static function has(): bool
/**
* Consume (read + remove) all messages that match any of the provided scopes.
* Global messages (scope=null) are included for every scope set.
*
* @param array<int, string> $scopes
* @return array<int, array<string, mixed>>
*/
public static function consumeForScopes(array $scopes): array
{
self::ensureSession();
return !empty($_SESSION[self::SESSION_KEY]);
$messages = $_SESSION[self::SESSION_KEY] ?? [];
if ($messages === []) {
return [];
}
$scopeSet = [];
foreach ($scopes as $scope) {
$scopeSet[$scope] = true;
}
if ($scopeSet === []) {
return [];
}
$consumed = [];
$remaining = [];
foreach ($messages as $message) {
$messageScope = $message['scope'] ?? null;
$matchesScope = $messageScope === null || isset($scopeSet[(string) $messageScope]);
if ($matchesScope) {
$consumed[] = $message;
continue;
}
$remaining[] = $message;
}
if ($remaining !== []) {
$_SESSION[self::SESSION_KEY] = $remaining;
} else {
unset($_SESSION[self::SESSION_KEY]);
}
return $consumed;
}
public static function dismiss(string $id): void
@@ -118,13 +157,4 @@ class Flash
unset($_SESSION[self::SESSION_KEY]);
}
}
// Prevents messages from being cleared on the next render — useful when a redirect chain crosses multiple pages.
public static function keep(int $times = 1)
{
self::ensureSession();
$times = max(1, $times);
$current = (int) ($_SESSION[self::KEEP_KEY] ?? 0);
$_SESSION[self::KEEP_KEY] = max($current, $times);
}
}

View File

@@ -38,6 +38,17 @@ function flashFormErrors(FormErrors $errors, ?string $scope = null, string $keyP
}
}
/**
* Consume and remove flash messages matching any provided scope.
*
* @param array<int, string> $scopes
* @return array<int, array<string, mixed>>
*/
function flashConsumeForScopes(array $scopes): array
{
return Flash::consumeForScopes($scopes);
}
function requestExtractNestedReturnValue(mixed $value): string
{
if (is_string($value)) {

View File

@@ -33,9 +33,6 @@ $returnToSanitized = $intendedUrlService->sanitize($returnTo !== '' ? $returnTo
$session = $sessionStore->all();
if (!empty($session['user']['id'])) {
if (Flash::has()) {
Flash::keep();
}
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
return;
}

View File

@@ -6,15 +6,12 @@ use MintyPHP\Session;
use MintyPHP\Support\Flash;
$target = Request::safeReturnTarget(requestInput()->bodyAll()['return'] ?? '');
$errorBag = formErrors();
if (requestInput()->method() !== 'POST') {
Router::redirect($target);
}
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
flashFormErrors($errorBag, null, 'flash_dismiss');
Router::redirect($target);
}

View File

@@ -1,10 +1,9 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Http\Request;
use MintyPHP\Session;
$messages = Flash::peek(Request::path()) ?: Flash::peek(Request::pathWithQuery());
$messages = is_array($appFlashMessages ?? null) ? $appFlashMessages : [];
if (!$messages) {
return;
}

View File

@@ -0,0 +1,84 @@
<?php
namespace MintyPHP\Tests\Unit\Support;
use MintyPHP\Support\Flash;
use PHPUnit\Framework\TestCase;
final class FlashTest extends TestCase
{
private array $sessionBackup = [];
protected function setUp(): void
{
parent::setUp();
$this->sessionBackup = $_SESSION ?? [];
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$_SESSION = [];
}
protected function tearDown(): void
{
$_SESSION = $this->sessionBackup;
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
parent::tearDown();
}
public function testConsumeForScopesReturnsMatchingAndLeavesOthers(): void
{
Flash::success('Saved', 'admin/users', 'saved');
Flash::error('Global error', null, 'global_error');
Flash::info('Other scope', 'admin/roles', 'other_scope');
$consumed = Flash::consumeForScopes(['admin/users', 'admin/users?active=1']);
$consumedKeys = array_column($consumed, 'key');
self::assertSame(['saved', 'global_error'], $consumedKeys);
$remaining = Flash::peek();
self::assertCount(1, $remaining);
self::assertSame('other_scope', $remaining[0]['key'] ?? null);
}
public function testConsumeForScopesMatchesPathAndPathWithQueryUnion(): void
{
Flash::success('From path', 'admin/users', 'path_only');
Flash::success('From path query', 'admin/users?tab=profile', 'path_query');
$consumed = Flash::consumeForScopes(['admin/users', 'admin/users?tab=profile']);
$consumedKeys = array_column($consumed, 'key');
self::assertSame(['path_only', 'path_query'], $consumedKeys);
self::assertSame([], Flash::peek());
}
public function testConsumeForScopesSupportsEmptyRootScope(): void
{
Flash::error('Root scoped', '', 'root_scope');
$consumed = Flash::consumeForScopes(['']);
self::assertCount(1, $consumed);
self::assertSame('root_scope', $consumed[0]['key'] ?? null);
self::assertSame([], Flash::peek());
}
public function testConsumeForScopesIsIdempotentPerRequestPath(): void
{
Flash::success('Saved once', 'admin/settings', 'settings_saved');
$firstConsume = Flash::consumeForScopes(['admin/settings']);
$secondConsume = Flash::consumeForScopes(['admin/settings']);
self::assertCount(1, $firstConsume);
self::assertCount(0, $secondConsume);
}
}

View File

@@ -318,6 +318,17 @@ if (Router::getTemplateView()) {
);
}
if (!$isApiRequest) {
// Flash messages are consumed before Session::end() so one-shot removal
// is persisted even though views are rendered afterwards.
$flashScopes = [];
$pathScope = Request::path();
$pathWithQueryScope = Request::pathWithQuery();
$flashScopes[$pathScope] = true;
$flashScopes[$pathWithQueryScope] = true;
$appFlashMessages = flashConsumeForScopes(array_keys($flashScopes));
}
// ── 12. Session & DB close ──────────────────────────────────────────
Session::end();