fix(flash): make flash messages one-shot per request
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
84
tests/Unit/Support/FlashTest.php
Normal file
84
tests/Unit/Support/FlashTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user