deps: major framework upgrades - #589
Merged
Merged
Conversation
CI and the Dockerfile have both been on Node 24 since spliit-app#543, but nothing in the manifest said so, and `npm ci` on an older Node would still start and then fail somewhere less obvious. Declaring it makes the requirement checkable up front. npm 11 is what produces the lockfile format already committed here.
Replaces the `autoprefixer` + `tailwindcss` PostCSS pair with the single `@tailwindcss/postcss` plugin (v4 handles vendor prefixing itself), swaps the `@tailwind` directives for `@import 'tailwindcss'`, and bumps `tailwind-merge` to 3 so it understands v4's class names. `tailwind.config.js` is unchanged. v4 reads it through the `@config` directive, which keeps the whole theme — the CSS-variable colour palette, the border-radius scale, the accordion keyframes — working exactly as before rather than porting it to CSS-first `@theme` in the same commit as the upgrade. Two default-scale changes in v4 reach this codebase, both checked against the compiled stylesheet rather than assumed: - `shadow-sm` now emits what v3 called `shadow` (a 3px blur instead of 2px). Three call sites: the card primitive, the recent-group card, and the active tab. `shadow-md` is unchanged. - `outline-none` now sets `outline-style: none`, where v3 set a transparent 2px outline. All 23 uses are `focus-visible:outline-none` paired with `focus-visible:ring-2`, so the focus indicator is unaffected in normal rendering; the difference only shows in forced-colors mode. `rounded-sm` is unaffected because the config overrides the radius scale, and no site uses a bare `ring` (v4's default drops 3px to 1px) — every one sets an explicit width.
zod 4 splits a schema's input and output types, which is the substantive change
here. `expenseFormSchema` coerces and transforms heavily — `z.coerce.date()`,
the string-or-number amount union, `.default()` on several fields — so what
react-hook-form holds while the user types is not what the resolver produces on
submit. Under zod 3 both were `z.infer` and the difference was papered over.
`ExpenseFormValues` is now `z.output` (unchanged in meaning, so no caller moves)
and a new `ExpenseFormInput` is `z.input`. The form is typed
`useForm<ExpenseFormInput, any, ExpenseFormValues>`, so field values are the raw
strings the inputs actually hold and the submit handler still receives parsed
values.
Two API changes come with the major:
- `required_error` is gone, replaced by the `error` callback. The callback
returns the existing message key only when the input is missing, which keeps
`SchemaErrors` translation lookups working exactly as before.
- `z.enum()` accepts an enum object directly, so the two Prisma enums lose a
double type argument and an `as any` each:
z.enum<SplitMode, [SplitMode, ...SplitMode[]]>(Object.values(SplitMode) as any)
z.enum(SplitMode)
The input/output split makes seven previously untyped reads explicit — mostly
`form.watch('expenseDate') as Date`, since the input type of `z.coerce.date()`
is `unknown`. They are narrow casts to real types rather than `any`, and they
mark exactly the places where raw form state is being read as parsed state.
`@hookform/resolvers` 5 is required: 3.x cannot express the three-generic form.
openai 6 is here because zod 4 forces it, not by preference: openai 4 declares
`peerOptional zod@"^3.23.8"`, so zod 4 and openai 4 cannot resolve together and
`npm install` fails outright. The SDK bump needs no code change — the existing
`chat.completions.create` call type-checks unmodified against 6.
Nine of the twelve `as any` casts in the expense form existed only because
`ExpenseFormValues` was a single type doing duty for both the raw form state
and the parsed result. With `ExpenseFormInput` describing what the fields
actually hold, the string values the form assigns to `shares`, `originalAmount`
and `paidFor` type-check on their own.
The three that remain are not zod's doing and are left alone:
- `shares: '1' as any` in `getDefaultSplittingOptions`, where `SplittingOptions`
models what localStorage holds (numbers) rather than form input.
- `form.setValue('splitMode', value as any)`, where Radix types
`onValueChange` as `(value: string) => void`.
- `isNaN(date as any)`, which wants `date.getTime()` rather than a cast.
Separated from the upgrade itself so it can be dropped without affecting it.
Four majors that mostly move on their own, plus the three code changes they force. `@types/react` and `@types/react-dom` go to 19, catching up with the `react` 19.2.8 already installed. They had been a major behind since spliit-app#479; it type-checked, but the types described a different React than the one running. TypeScript 6 needs two things: - `types: ["node", "jest"]`. TS 6 changed how ambient `@types` packages resolve, and without the list every `@types/*` in the tree loads into every compilation. - That narrowing removes the ambient `Global` interface that `src/lib/prisma.ts` used for its dev-mode client cache, so it now uses `globalThis`, which needs no ambient declaration. TypeScript 6 also added the TS5011 diagnostic, which breaks `npm run generate-currency-data` — `ts-node` resolves a common source directory of `./src/scripts` and now demands an explicit `rootDir`. Setting it to the project root fixes the script and changes nothing else, since `noEmit` is set and every included file already lives under it. Confirmed by regenerating: `currency-data.json` comes back byte-identical. lucide-react 1 dropped its brand icons, so the GitHub mark on the home page comes from `@radix-ui/react-icons`, which is already a direct dependency and already supplies the same mark in the group list and the theme toggle. jest 30 and `prettier-plugin-organize-imports` 4 need no changes: 148 tests pass unmodified and the formatter produces identical output.
The riskiest commit in this PR, kept last and self-contained so it can be dropped with a rebase if you would rather take it separately. Prisma 7 changes three things that reach this codebase: 1. **Connection URLs leave the schema.** `url` and `directUrl` are no longer valid in `datasource db`, so they move to a new `prisma.config.ts`, which Migrate reads. The same environment variables are used, in the same order, so no deployment needs to change anything: `POSTGRES_PRISMA_URL` and `POSTGRES_URL_NON_POOLING` keep their current meaning. 2. **The client needs an explicit driver adapter.** `src/lib/prisma.ts` builds a `PrismaPg` adapter over `POSTGRES_PRISMA_URL`. `pg` is already a dependency. 3. **The client is generated into the source tree**, at `src/generated/prisma`, rather than into node_modules. 17 imports move from `@prisma/client` to `@/generated/prisma/client` (server) or `@/generated/prisma/browser` (anything reachable from a client component). The directory is gitignored, dockerignored, and excluded from linting. Two consequences worth calling out, both found by building rather than by reading the changelog: - `src/lib/api.ts` exported `randomId`, and two client components imported it from there. Under Prisma 6 that was harmless; under Prisma 7 it pulls the pg adapter and `pg` itself into the browser bundle and the build fails on Node built-ins. `randomId` moves to `src/lib/random.ts` with no Prisma in its import graph, and `api.ts` re-exports it so server-side callers are unchanged. - The browser namespace exports `Prisma.Decimal` as a value only, so `src/trpc/client.tsx` derives the instance type with `InstanceType<typeof ...>` for its superjson registration. Dockerfile: `prisma generate` moves after the source copy (the output path is now inside `src/`), `prisma.config.ts` is copied into both the build and the runtime stage, and the runtime stage stops regenerating the client — the build bundles it into `.next`, and that stage has no source tree to generate from.
BastiOfBerlin
commented
Aug 16, 2026
BastiOfBerlin
left a comment
Collaborator
Author
There was a problem hiding this comment.
looks good to me
Collaborator
Author
|
@scastiel Please have a look at this and merge if you like. I'll wait a bit before I do so without a second opinion. |
24 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
deps: major framework upgrades
Part of the series in #553, and the one I'd most like a second opinion on. Six
commits, 31 files plus the lockfile.
Lead with the evidence, because it is the whole argument for this PR: your
E2E suite passes 41/41 against this branch — a real Chromium against a real
Postgres 16, the full migration history applied from scratch, the built app
served by
next start. Every upgrade below is exercised by that run, includingthe one that changes how deployments connect to the database.
What moves
@hookform/resolvers@types/react/-dom@types/nodeVersions are compared against what the lockfile actually installs, not what the
manifest declares — the method note from #576, which is also why
nextisabsent from this table: 16.3.1 is already installed here.
Six commits, in review order
engines— Node 24 / npm 11, which CI and the Dockerfile have usedsince Feat : Update dependencies #543.
split off with one rebase.
Every commit was checked to resolve independently —
npm install --dry-runagainst each of the six, no ERESOLVE. That check is here because it caught a
real defect: see the openai note below.
Prisma 7 — the part that needs your judgement
It changes how every deployment reaches Postgres, so it is the last commit and
nothing else depends on it.
schema.prismafor a newprisma.config.ts. Thesame two variables are read in the same order, so no self-hoster has to
change any configuration:
POSTGRES_PRISMA_URLandPOSTGRES_URL_NON_POOLINGkeep their exact current meaning.PrismaPgover the pooledURL.
pgwas already a dependency.src/generated/prismarather than node_modules,so 17 imports move to
@/generated/prisma/clientor.../browser. Thedirectory is gitignored, dockerignored and lint-excluded.
What I could verify, and did, rather than reasoning from the changelog:
prisma migrate deployapplies the full migration history to an emptydatabase under Prisma 7.
npm ci --omit=dev --ignore-scripts) — the exact tree the runtime stage has, which is what thecontainer runs at start-up. This was the failure mode I most expected and it
does not happen.
by deleting
src/generatedand starting the server: it serves and reaches thedatabase, because the build bundles the client into
.next. So the runtimestage stops running
prisma generateentirely.Two consequences found by building rather than reading:
src/lib/api.tsexportedrandomId, and two client components imported itfrom there. Harmless under Prisma 6; under Prisma 7 it drags the pg adapter
and
pginto the browser bundle and the build fails.randomIdmoves tosrc/lib/random.ts, andapi.tsre-exports it so server callers areuntouched.
Prisma.Decimalas a value only, sosrc/trpc/client.tsxderives the instance type for its superjsonregistration.
What I could not verify:
docker builditself. There is no Docker daemonwhere I work. The Dockerfile changes are three targeted edits — copy
prisma.config.ts, moveprisma generateafter the source copy, drop theregenerate from the runtime stage — and each was reasoned from a behaviour I
tested outside the image, but the build has not been run. That is the one thing
this PR needs from someone who can run it.
zod 4 is the most interesting change
zod 4 splits a schema's input and output types, and
expenseFormSchemacoercesheavily —
z.coerce.date(), the string-or-number amount union, several.default()s. So what react-hook-form holds while you type genuinely is notwhat the resolver returns on submit. Under zod 3 both were
z.inferand thegap was papered over with casts.
ExpenseFormValuesstaysz.output(so no caller changes) and a newExpenseFormInputisz.input, with the form typeduseForm<ExpenseFormInput, any, ExpenseFormValues>.The measurable payoff is commit 4: nine of the twelve
as anycasts inexpense-form.tsxdisappear, because the string values the form assigns toshares,originalAmountandpaidFornow type-check as themselves. The twoas anyinschemas.tsgo too, sincez.enum()takes an enum object directly:Being straight about the trade: the upgrade also adds seven casts, mostly
form.watch('expenseDate') as Date, because the input type ofz.coerce.date()is
unknown. Those are narrow casts to real types rather thanany, and theysit exactly where raw form state is read as parsed state. Net across the file:
twelve
anybecome threeanyplus seven typed casts.The three casts left are not zod's doing and I left them alone rather than
widen the diff —
SplittingOptionsmodelling what localStorage holds, Radixtyping
onValueChangeas(value: string), and anisNaN(date as any)thatreally wants
date.getTime().required_erroris gone in zod 4, replaced by anerrorcallback. The callbackreturns the existing message key only when the input is missing, which I checked
directly rather than trusting: parsing
{}still yieldstitleRequired,amountRequiredandpaidByRequired, and a one-character title still yieldsmin2rather than the required key. SoSchemaErrorslookups are unchanged.openai 6 is in this PR because zod 4 forces it, not because I wanted to
bundle it. openai 4 declares
peerOptional zod@"^3.23.8", so zod 4 andopenai 4 cannot resolve together —
npm installfails outright with ERESOLVE.I originally had openai queued for a later PR and only found this because I
build-checked each commit in isolation; the first version of the zod commit
carried a lockfile that npm had produced by skipping re-resolution against an
existing tree, and it would have failed for anyone installing from clean. The
SDK bump needs no code change: the existing
chat.completions.createcalltype-checks against 6 unmodified.
Tailwind 4
tailwind.config.jsis unchanged. v4 reads it through@config, whichkeeps the CSS-variable palette, the radius scale and the accordion keyframes
working as-is rather than rewriting the theme into CSS-first
@themein thesame commit as the upgrade. Worth doing eventually; not worth doing here.
Two default-scale changes reach this codebase. I compared the compiled
stylesheet instead of guessing:
shadow-smnow emits what v3 calledshadow— a 3px blur instead of 2px.Three call sites: the card primitive, the recent-group card, the active tab.
shadow-mdis unchanged.outline-nonenow setsoutline-style: none, where v3 set a transparent2px outline. All 23 uses pair it with
focus-visible:ring-2, so the focusindicator is unaffected in normal rendering; the difference only shows in
forced-colors mode.
rounded-smis unaffected because the config overrides the radius scale, and nosite uses a bare
ring, so v4 dropping the default ring from 3px to 1px doesnot bite. Net: this is a compatibility-mode migration, not a v4-idiom migration,
and the only visual delta I can find is three slightly heavier shadows.
TypeScript 6 and the rest
types: ["node", "jest"]— TS 6 changed ambient@typesresolution, andwithout the list every
@types/*in the tree loads into every compilation.That narrowing removes the ambient
Globalinterfacesrc/lib/prisma.tsused, so it now uses
globalThis.npm run generate-currency-data. The new TS5011 diagnosticmakes
ts-nodedemand an explicitrootDir. Setting it to the project rootfixes the script and changes nothing else, since
noEmitis set and everyincluded file is already under it. Confirmed by regenerating:
currency-data.jsoncomes back byte-identical — which incidentallyre-verifies fix: repair main after the MYR, MKD and COP merges #557's repair.
@types/react19 catches up with thereact19.2.8 already installed; theyhad been a major apart since Upgrade dependencies #479, flagged in chore: update routine deps #576.
page comes from
@radix-ui/react-icons— already a direct dependency, andalready the source of that same mark in the group list and the theme toggle.
prettier-plugin-organize-imports4 needed nothing: 148 testspass unmodified and the formatter produces identical output.
Verification
Against
4983038, Node 24 / npm 11:npm ci --ignore-scripts,npx prisma generate,check-types,check-formatting,npm test(9 suites,148 tests),
npm run lint(19 warnings / 0 errors — unchanged frommain),and a full
npm run build.E2E: 41/41. Real Postgres 16, migrations applied from empty, the built app
under
next start, driven throughE2E_BASE_URL. Notscripts/e2e.shand notthe image build — same app and same specs, but it does not exercise Docker.
What I deliberately left out
openai 7,
content-disposition3,@types/node26,react-intersection-observer11,@testing-library/jest-dom7. Every one isa version this combination has never run. The strongest thing this PR has is
that the exact set above has been running in my fork in production and passes
your suite; adding untried versions would spend that for nothing. CI Baseline #564's
grouped Dependabot config will propose them on its own schedule, which is the
right way for them to arrive.
@thememigration oftailwind.config.js, per above.npm auditadvisories (transitiveuuidvianext-s3-upload) are unchanged before and after this PR.