fix(flash): make flash messages one-shot per request
This commit is contained in:
@@ -8,7 +8,6 @@ use MintyPHP\Session;
|
|||||||
class Flash
|
class Flash
|
||||||
{
|
{
|
||||||
private const SESSION_KEY = 'flash_messages';
|
private const SESSION_KEY = 'flash_messages';
|
||||||
private const KEEP_KEY = 'flash_keep';
|
|
||||||
|
|
||||||
private static function ensureSession()
|
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();
|
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
|
public static function dismiss(string $id): void
|
||||||
@@ -118,13 +157,4 @@ class Flash
|
|||||||
unset($_SESSION[self::SESSION_KEY]);
|
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
|
function requestExtractNestedReturnValue(mixed $value): string
|
||||||
{
|
{
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ $returnToSanitized = $intendedUrlService->sanitize($returnTo !== '' ? $returnTo
|
|||||||
$session = $sessionStore->all();
|
$session = $sessionStore->all();
|
||||||
|
|
||||||
if (!empty($session['user']['id'])) {
|
if (!empty($session['user']['id'])) {
|
||||||
if (Flash::has()) {
|
|
||||||
Flash::keep();
|
|
||||||
}
|
|
||||||
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
|
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,12 @@ use MintyPHP\Session;
|
|||||||
use MintyPHP\Support\Flash;
|
use MintyPHP\Support\Flash;
|
||||||
|
|
||||||
$target = Request::safeReturnTarget(requestInput()->bodyAll()['return'] ?? '');
|
$target = Request::safeReturnTarget(requestInput()->bodyAll()['return'] ?? '');
|
||||||
$errorBag = formErrors();
|
|
||||||
|
|
||||||
if (requestInput()->method() !== 'POST') {
|
if (requestInput()->method() !== 'POST') {
|
||||||
Router::redirect($target);
|
Router::redirect($target);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Session::checkCsrfToken()) {
|
if (!Session::checkCsrfToken()) {
|
||||||
$errorBag->addGlobal(t('Form expired, please try again'));
|
|
||||||
flashFormErrors($errorBag, null, 'flash_dismiss');
|
|
||||||
Router::redirect($target);
|
Router::redirect($target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use MintyPHP\Support\Flash;
|
|
||||||
use MintyPHP\Http\Request;
|
use MintyPHP\Http\Request;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
|
|
||||||
$messages = Flash::peek(Request::path()) ?: Flash::peek(Request::pathWithQuery());
|
$messages = is_array($appFlashMessages ?? null) ? $appFlashMessages : [];
|
||||||
if (!$messages) {
|
if (!$messages) {
|
||||||
return;
|
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 ──────────────────────────────────────────
|
// ── 12. Session & DB close ──────────────────────────────────────────
|
||||||
|
|
||||||
Session::end();
|
Session::end();
|
||||||
|
|||||||
Reference in New Issue
Block a user