Upgrades Inertia.js from v2 to v3 on both sides — the major deliberately
left out of #764.
| Package | From | To |
| --- | --- | --- |
| `inertiajs/inertia-laravel` | 2.0.24 | **3.3.1** |
| `@inertiajs/react` | 2.3.17 | **3.6.1** |
| `axios` | transitive | **direct dep** (1.19.0) |
## Why axios becomes a direct dependency
The app imports `axios` in 18 runtime files, but it was never declared —
it arrived transitively through `@inertiajs/core` v2. v3 drops axios in
favour of its own XHR client, so without this the whole app would fail
to resolve the import.
Nothing relied on Inertia configuring axios: v2 never touched
`axios.defaults`, and there are no interceptors. `^1.19.0` also
satisfies core's optional peer range (`^1.15.2`).
## Breaking changes swept
Every v2→v3 breaking change from the [official upgrade
guide](https://inertiajs.com/upgrade-guide) was checked against the
codebase:
| Change | Call sites |
| --- | --- |
| `<title inertia>` → `<title data-inertia>` | fixed (`app.blade.php`) |
| `testing.page_paths/page_extensions` → `pages.paths/extensions` |
fixed (`config/inertia.php`) |
| `ComponentResolver` no longer accepts `Promise<{default}>` | fixed
(`app.tsx`, `ssr.tsx`) |
| `router.on('invalid'/'exception')` → `httpException`/`networkError` |
none — only `'navigate'` is used |
| `router.cancel()` → `cancelAll()` | none |
| `Inertia::lazy()` → `optional()` | none |
| `future` options block | none |
| `hideProgress`/`revealProgress` | none |
| arrow-fn `.layout =` → `[Layout]` | none — no page sets `.layout` |
| deprecated testing traits (`Has`/`Matching`/`Debugging`) | none |
| `qs` / `lodash-es` imports | none |
React 19.2.4, Laravel 13 and PHP 8.4 already satisfy v3's raised floors.
## DevTools kept opt-in
v3 adds a DevTools recorder that writes every request's page props to
`storage/inertia-devtools/` and **defaults to on in local**. On this app
that produced 38 dumps, the largest ~1 MB, holding whole transaction and
balance payloads — redaction only covers credential-shaped keys. It is
gitignored by the package, so it never reaches the repo, but for a
privacy-first product it shouldn't be the default. Gated behind
`INERTIA_DEVTOOLS_ENABLED` (set it to `true` in your own `.env` when you
need it).
## Deliberate behaviour change: `<Deferred>` on partial reloads
v3 no longer resets `<Deferred>` to its `fallback` during a partial
reload — existing content stays visible while fresh data loads. This
affects the dashboard (`onBalanceUpdated`) and account detail
(`handleTransactionCreated`).
**Kept as-is, on purpose.** Two reasons:
1. `dashboard.tsx:131-133` already carried a workaround for exactly this
v2 behaviour — it requests an unrelated cheap prop purely so the
deferred prop isn't refetched into a skeleton. The team already treated
the skeleton flash as a problem and v3 fixes it upstream, so restoring
it via the new `reloading` slot prop would undo an improvement. That
workaround is now redundant and can be simplified in a follow-up.
2. The "user can't tell it worked and re-submits" risk doesn't apply:
`edit-transaction-dialog.tsx:390` fires `toast.success('Transaction
created successfully')` *before* the reload, so the confirmation is
explicit and independent of the list.
## Verification
Full CI set, locally:
- `bun run build` — green
- `bun run test` — **353/353**
- `bun run types` — zero new errors (advisory step; pre-existing backlog
unchanged)
- `bun run format`, `bun run lint`, `bun run dry` — green
- `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` — green
- `php artisan crap --base=origin/main` — 0 methods above complexity 10
- `pest --exclude-testsuite=Browser,Performance` — **2086/2088**
- `pest --testsuite=Browser` — **132/133**
Both test failures are pre-existing and reproduce identically on `main`
with Inertia v2 — I ran each one there to confirm:
- `DashboardTest::dashboard top categories roll child spending up into
the parent` — 409, the known local Inertia asset-version artifact
- `BudgetsFeatureNavigationTest::user cannot access another users
budget` — `Unknown column 'category_id'` on `budgets`, a factory/schema
mismatch
## Browser QA
Real browser sweep against the production build over dashboard,
transactions, accounts, budgets, settings and billing: **0 console
errors, 0 uncaught exceptions, 0 failed requests**. Page titles apply,
deferred props resolve, sidebar `<Link>` navigation stays client-side
with no document reloads, back/forward restores from Inertia history,
and a profile form submit persisted correctly.
## SSR: measured, left alone
v3 routes SSR through the Vite dev server (`/__inertia_ssr`) whenever
Vite is running hot, and we don't use the `@inertiajs/vite` plugin — so
in theory local dev makes a doomed POST per render. Measured it rather
than guessed: with `bun run dev` up, page loads are **16 ms** and the
failed attempt is a connection-refused costing ~0.6 ms, then it falls
back to client rendering. Dev mode renders correctly (React mounts, 0
console errors). Not worth a config knob.
Production is untouched — `Vite::isRunningHot()` is false there, so it
uses the real SSR server from `docker/supervisor/supervisord.conf`
exactly as on v2.
## Notes
- Wayfinder needed nothing: #764 already brought it to v0.1.21 (latest),
and `@laravel/vite-plugin-wayfinder` is on 0.1.7 (latest).
- Pre-existing bug both reviews surfaced, left for its own PR:
`auth/register.tsx:40` passes an `async` handler to `<Form onBefore>`,
which Inertia never awaits (same in v2 and v3), so
`transactionSyncService.clearAll()` is fire-and-forget on registration.
- `config/inertia.php` is hand-patched rather than republished. The v3
stub is ~190 lines; the unset keys resolve to package defaults via
`mergeConfigFrom`. Adopting the whole stub is a separate, reviewable
change.
- Remaining "Inertia v2" strings live inside Boost-generated blocks
(`CLAUDE.md`'s guidelines block and the vendored `SKILL.md` copies).
Hand-edits there get overwritten, so they should come from a Boost
regeneration. The three hand-written references are updated here.
## Demo
https://github.com/user-attachments/assets/3f0e7f6d-621a-4c3a-a2a0-68473ec59f10
<!-- PLACEHOLDER: drag the QA video in here -->
## Summary
Follow-up to #623 — addresses security findings **not covered** by the
maintainer's draft PR #627. The original scope was larger; out-of-scope
changes were reverted (see `revert: drop out-of-scope changes from
security round 2`) and are **no longer part of this PR** (details under
_Reverted / not included_).
## Changes
### Rate limiting
- `routes/api.php`: authenticated API group throttled at `300,1` (per
user). An SPA with offline sync + multi-widget dashboards fans out many
requests per interaction, so a tighter cap throttled legitimate bursts.
- `routes/web.php`: unauthenticated open-banking callback throttled at
`30,1` (per IP), enough headroom for browser retries and the iOS PWA →
Safari hand-off that can fire the callback twice.
- `use-decrypt-transactions.ts`: the legacy decrypt migration loops
without backoff (~3 requests per 100 encrypted transactions), so a large
account could hit the API throttle and abort the whole migration
silently. It now honours Laravel's `Retry-After` header on 429 and
retries the same request instead of bailing.
### State token persistence on failure
- `AuthorizationController::callback`: clears `state_token` on the
connection when EnableBanking session creation fails, preventing a stale
token from being replayed.
### Trust proxies hardening
- `bootstrap/app.php`: replaced `trustProxies(at: '*')` with an
env-based `TRUSTED_PROXIES` config. When unset, no proxy is trusted
(Laravel default) instead of trusting every IP.
- ⚠️ **Deploy requirement:** production runs behind a reverse proxy /
TLS terminator (`Dockerfile.production` + nginx serve HTTP on :80). With
`TRUSTED_PROXIES` unset, Laravel stops trusting
`X-Forwarded-For`/`X-Forwarded-Proto` → client IPs collapse to the proxy
IP (contaminating logs and per-IP throttling) and HTTPS detection
breaks. `TRUSTED_PROXIES` **must** be set in the production environment
before merge, and should be added to `.env.production.example`.
### XSS-safe Blade-to-JS injection
- `app.blade.php`: replaced `{{ }}` Blade syntax inside `<script>` with
`@json()` to prevent JS injection via variable content.
### TOCTOU defense-in-depth
- `Api/TransactionController::bulkUpdate`: added `->where('user_id',
$userId)` to the per-row UPDATE as belt-and-suspenders behind the
existing `abort(403)` ownership pre-check.
## Reverted / not included
The following were in the original description but were reverted and are
**not** in the current diff:
- `block-demo` middleware on additional route groups (already present
upstream on `routes/settings.php`).
- `->limit(500)` / `has_more` / `since` validation on
`TransactionSyncController`.
## Notes
- `vite.config.ts` appears in the diff only because of an older
merge-base; it is identical to `main` and nets to no change (will vanish
on rebase).
- No tests yet for the `state_token` clearing on `createSession` failure
or for the throttles — worth adding before merge (the callback throttle
is the most exposed surface).
---------
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## Sentry issue
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1S
## Root cause
- Browser/page translation mutated React-managed DOM on /onboarding by
injecting font wrappers. React later attempted to unmount a node that
translation had moved, causing NotFoundError removeChild.
## Fix
- Mark the Inertia root document as not translatable with
translate="no", notranslate class, and google notranslate meta tag.
- Add regression coverage for the root template markers.
## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Sentry
- Fixes PHP-LARAVEL-1Q
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1Q
## Root cause
- Service worker registration promise rejected in production and had no
rejection handler.
- Browser reported unhandled promise rejection: Error: Rejected.
## Fix
- Add a catch handler to service worker registration so
unsupported/intercepted registration failures do not become unhandled
global errors.
- Add regression coverage asserting registration has rejection handling.
## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Summary
- switch the iOS standalone PWA status bar style from
`black-translucent` to `default`
- keep installed iOS app content below the status bar and notch area
- extend the PWA template test to guard against regressing to
translucent overlap
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Why
### Problem
Two iOS-specific UX issues reported by users:
1. **App icon invisible on light wallpapers** — the home screen shortcut
icon was a black logo on a white background, making it nearly invisible
against light iOS wallpapers (appeared as a faint frosted outline).
2. **Country dropdown overflows viewport** — the country picker in the
Connect Account flow exceeded the mobile viewport height, making it
impossible to scroll through the full list on small screens.
### Root Cause
1. All icon PNGs were exported with a white background and a black/grey
logo. The PWA manifest `background_color` was also `#ffffff`. No dark
background was baked in.
2. `SelectContent` used a fixed `max-h-96` (384px) regardless of
available screen space. The `SelectPrimitive.Viewport` height was bound
to `--radix-select-trigger-height` (the trigger element's height)
instead of the actual available viewport space, preventing Radix's
internal scroll buttons from working correctly on mobile.
## What
### Changes
- Regenerated all icon PNGs (favicon, apple-touch-icon,
web-app-manifest, whispermoney_icon_x*) — white logo on `#1b1b18` dark
background via ImageMagick Screen composite
- Updated `site.webmanifest` `background_color` from `#ffffff` →
`#1b1b18`
- Fixed `SelectContent` max height: `max-h-96` →
`max-h-[min(24rem,var(--radix-select-content-available-height))]`
- Fixed `SelectPrimitive.Viewport` height:
`--radix-select-trigger-height` →
`--radix-select-content-available-height`
## Verification
### Tests
No automated tests cover static asset generation or CSS utility values —
these are visual/rendering fixes.
### Manual
- iOS: Remove and re-add the home screen shortcut from Safari to pick up
the new `apple-touch-icon.png`. Icon should show a white bird logo on a
dark background, visible on any wallpaper.
- Mobile (iOS/Android): Open Connect Account dialog and verify the
country dropdown fits within the viewport and is fully scrollable.
## Summary
- Add a new **Chart color scheme** dropdown in Settings > Appearance
allowing users to choose between 4 color palettes: **Colorful** (new
default), **Neutral** (previous zinc grayscale), **Blue**, and **Pink**
- Persist the setting in a new `user_settings` table with instant
client-side feedback via localStorage + cookie
- All charts across dashboard, categories, budgets, and cashflow update
instantly when switching schemes
- Reduced colorful palette intensity (shifted from 500/600 to 300/400
range) for lower contrast
## Summary
- Add service worker registration and `viewport-fit=cover` for proper
iOS standalone mode (no Safari chrome)
- Change manifest `start_url` to `/dashboard` so PWA users skip the
landing page
- Add tests for PWA configuration
## Test plan
- [x] Deploy and re-add PWA to iOS home screen — should open as
standalone (no address bar)
- [x] PWA launch should go to `/dashboard` instead of the landing page
- [x] Non-authenticated PWA users should be redirected to login
Closes#71
## Changes
- Updated `site.webmanifest` with proper PWA configuration:
- Added all maskable icons (48, 72, 96, 128, 192, 384, 512)
- Set theme_color and background_color for native feel
- Added start_url, scope, id, description, and categories
- Enhanced iOS/Safari support in `app.blade.php`:
- Added `apple-mobile-web-app-capable` for fullscreen mode
- Added `apple-mobile-web-app-status-bar-style: black-translucent` for
integrated status bar
- Added `mobile-web-app-capable` for Android
The dynamic theme-color meta tags (`#ffffff` for light, `#383838` for
dark) match the app's background colors, ensuring the status bar blends
seamlessly with the app.