fix(cashflow): read period from server props instead of window (#302)

## 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
This commit is contained in:
Víctor Falcón 2026-04-17 10:27:49 +01:00 committed by GitHub
parent cfa54a2d9d
commit 22952c4e75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 71 additions and 13 deletions

View File

@ -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,
]);
}
}

View File

@ -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<Date>(() => {
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 (
<AppSidebarLayout breadcrumbs={breadcrumbs}>

View File

@ -0,0 +1,56 @@
<?php
use App\Models\User;
use Inertia\Testing\AssertableInertia;
test('guests are redirected to login', function () {
$this->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)
);
});