From 22952c4e75cfbe933b42c91da826ff0e33e472e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 17 Apr 2026 10:27:49 +0100 Subject: [PATCH] fix(cashflow): read period from server props instead of window (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes [PHP-LARAVEL-9](https://whisper-money.sentry.io/issues/PHP-LARAVEL-9): `ReferenceError: window is not defined` during Inertia SSR of `CashflowPage`. - `CashflowController` now reads + validates the `period` query param (`YYYY-MM`) and passes it as an Inertia prop. - Client `useState` initializer reads from `usePage().props.period` instead of `window.location.search`, making SSR safe and avoiding hydration mismatch. - `useEffect` URL sync now compares against the prop, not `window.location.search`. ## Why (c) not a `typeof window` guard Server-provided params avoid hydration mismatch and keep URL as single source of truth. ## Tests New `tests/Feature/CashflowPageTest.php`: - guest redirect - no query param → `period` prop `null` - valid `?period=2025-03` → prop `'2025-03'` - invalid value → `null` - malformed format (`2025-3`) → `null` All 5 pass (50 assertions). ## Scope Cashflow only. Other pages with similar `window.location.search` in `useState` initializers (`onboarding/index.tsx`, `welcome.tsx`, `auth/login.tsx`) left for follow-up. Fixes PHP-LARAVEL-9 --- app/Http/Controllers/CashflowController.php | 6 +++ resources/js/pages/cashflow/index.tsx | 22 ++++---- tests/Feature/CashflowPageTest.php | 56 +++++++++++++++++++++ 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/CashflowPageTest.php diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index 368ede6a..5a162696 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -34,10 +34,16 @@ class CashflowController extends Controller ->orderBy('name') ->get(['id', 'name', 'logo']); + $period = $request->query('period'); + $validPeriod = is_string($period) && preg_match('/^\d{4}-\d{2}$/', $period) === 1 + ? $period + : null; + return Inertia::render('cashflow/index', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'period' => $validPeriod, ]); } } diff --git a/resources/js/pages/cashflow/index.tsx b/resources/js/pages/cashflow/index.tsx index 1d3ef713..4add27bc 100644 --- a/resources/js/pages/cashflow/index.tsx +++ b/resources/js/pages/cashflow/index.tsx @@ -22,18 +22,17 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function CashflowPage() { - const { auth } = usePage<{ auth: { user: { currency_code: string } } }>() - .props; + const { auth, period: initialPeriod } = usePage<{ + auth: { user: { currency_code: string } }; + period: string | null; + }>().props; - // Initialize currentDate from URL query param or default to current month + // Initialize currentDate from server-provided period prop or default to current month const [currentDate, setCurrentDate] = useState(() => { - const urlParams = new URLSearchParams(window.location.search); - const periodParam = urlParams.get('period'); - - if (periodParam) { + if (initialPeriod) { try { // Parse YYYY-MM format - const parsedDate = parse(periodParam, 'yyyy-MM', new Date()); + const parsedDate = parse(initialPeriod, 'yyyy-MM', new Date()); // Validate it's a valid date if (!isNaN(parsedDate.getTime())) { return parsedDate; @@ -63,19 +62,16 @@ export default function CashflowPage() { // Update URL when currentDate changes useEffect(() => { const periodParam = format(currentDate, 'yyyy-MM'); - const currentPeriodParam = new URLSearchParams( - window.location.search, - ).get('period'); // Only update if the period has changed - if (currentPeriodParam !== periodParam) { + if (initialPeriod !== periodParam) { router.visit(cashflow({ query: { period: periodParam } }).url, { preserveScroll: true, preserveState: true, replace: true, }); } - }, [currentDate]); + }, [currentDate, initialPeriod]); return ( diff --git a/tests/Feature/CashflowPageTest.php b/tests/Feature/CashflowPageTest.php new file mode 100644 index 00000000..1ad36781 --- /dev/null +++ b/tests/Feature/CashflowPageTest.php @@ -0,0 +1,56 @@ +get(route('cashflow'))->assertRedirect(route('login')); +}); + +test('period prop is null when no query param given', function () { + $this->actingAs(User::factory()->onboarded()->create()); + + $this->get(route('cashflow')) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('cashflow/index') + ->where('period', null) + ); +}); + +test('valid period query param is passed to page props', function () { + $this->actingAs(User::factory()->onboarded()->create()); + + $this->get(route('cashflow', ['period' => '2025-03'])) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('cashflow/index') + ->where('period', '2025-03') + ); +}); + +test('invalid period query param is sanitized to null', function () { + $this->actingAs(User::factory()->onboarded()->create()); + + $this->get(route('cashflow', ['period' => 'not-a-date'])) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('cashflow/index') + ->where('period', null) + ); +}); + +test('malformed period format is rejected', function () { + $this->actingAs(User::factory()->onboarded()->create()); + + $this->get(route('cashflow', ['period' => '2025-3'])) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('cashflow/index') + ->where('period', null) + ); +});