diff --git a/core/Support/Flash.php b/core/Support/Flash.php index 74b0296..7e6f2cf 100644 --- a/core/Support/Flash.php +++ b/core/Support/Flash.php @@ -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 $scopes + * @return array> + */ + 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); - } } diff --git a/core/Support/helpers/request.php b/core/Support/helpers/request.php index cfa8680..7c6da35 100644 --- a/core/Support/helpers/request.php +++ b/core/Support/helpers/request.php @@ -38,6 +38,17 @@ function flashFormErrors(FormErrors $errors, ?string $scope = null, string $keyP } } +/** + * Consume and remove flash messages matching any provided scope. + * + * @param array $scopes + * @return array> + */ +function flashConsumeForScopes(array $scopes): array +{ + return Flash::consumeForScopes($scopes); +} + function requestExtractNestedReturnValue(mixed $value): string { if (is_string($value)) { diff --git a/pages/auth/login().php b/pages/auth/login().php index 5699937..5c7c8d1 100644 --- a/pages/auth/login().php +++ b/pages/auth/login().php @@ -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; } diff --git a/pages/flash/dismiss($id).php b/pages/flash/dismiss($id).php index a6b1f03..39042ca 100644 --- a/pages/flash/dismiss($id).php +++ b/pages/flash/dismiss($id).php @@ -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); } diff --git a/templates/partials/app-flash.phtml b/templates/partials/app-flash.phtml index 16c1e68..580bf08 100644 --- a/templates/partials/app-flash.phtml +++ b/templates/partials/app-flash.phtml @@ -1,10 +1,9 @@ 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); + } +} diff --git a/web/index.php b/web/index.php index e98d1a8..d1dd49f 100644 --- a/web/index.php +++ b/web/index.php @@ -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();