Skip to content

deps: major framework upgrades - #589

Merged
BastiOfBerlin merged 6 commits into
spliit-app:mainfrom
BastiOfBerlin:up-11-major-deps
Aug 17, 2026
Merged

deps: major framework upgrades#589
BastiOfBerlin merged 6 commits into
spliit-app:mainfrom
BastiOfBerlin:up-11-major-deps

Conversation

@BastiOfBerlin

Copy link
Copy Markdown
Collaborator

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, including
the one that changes how deployments connect to the database.

What moves

from to
Tailwind 3.4.19 4.3.2
zod 3.25.76 4
@hookform/resolvers 3.10.0 5
openai 4.104.0 6
TypeScript 5.9.3 6.0.3
jest 29.7.0 30.4.2
lucide-react 0.501.0 1.17.0
@types/react / -dom 18.3.x 19
@types/node 20.19.43 24
Prisma 6.19.3 7.9.1

Versions are compared against what the lockfile actually installs, not what the
manifest declares — the method note from #576, which is also why next is
absent from this table: 16.3.1 is already installed here.

Six commits, in review order

  1. engines — Node 24 / npm 11, which CI and the Dockerfile have used
    since Feat : Update dependencies #543.
  2. Tailwind 4.
  3. zod 4 + resolvers 5 + openai 6.
  4. Dropping the casts zod 4 makes unnecessary. Droppable on its own.
  5. TypeScript 6, jest 30, lucide-react 1, React 19 types.
  6. Prisma 7 + the pg driver adapter. Last and self-contained, so it can be
    split off with one rebase.

Every commit was checked to resolve independently — npm install --dry-run
against 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.

  • Connection URLs leave schema.prisma for a new prisma.config.ts. The
    same two variables are read in the same order, so no self-hoster has to
    change any configuration
    : POSTGRES_PRISMA_URL and
    POSTGRES_URL_NON_POOLING keep their exact current meaning.
  • The client needs an explicit driver adapterPrismaPg over the pooled
    URL. pg was already a dependency.
  • The client generates into src/generated/prisma rather than node_modules,
    so 17 imports move to @/generated/prisma/client or .../browser. The
    directory is gitignored, dockerignored and lint-excluded.

What I could verify, and did, rather than reasoning from the changelog:

  • prisma migrate deploy applies the full migration history to an empty
    database
    under Prisma 7.
  • It also works from a production-only install (npm ci --omit=dev --ignore-scripts) — the exact tree the runtime stage has, which is what the
    container runs at start-up. This was the failure mode I most expected and it
    does not happen.
  • The runtime stage does not need the generated client copied in. I checked
    by deleting src/generated and starting the server: it serves and reaches the
    database, because the build bundles the client into .next. So the runtime
    stage stops running prisma generate entirely.

Two consequences found by building rather than reading:

  • src/lib/api.ts exported randomId, and two client components imported it
    from there. Harmless under Prisma 6; under Prisma 7 it drags the pg adapter
    and pg into the browser bundle and the build fails. randomId moves to
    src/lib/random.ts, and api.ts re-exports it so server callers are
    untouched.
  • The browser namespace exports Prisma.Decimal as a value only, so
    src/trpc/client.tsx derives the instance type for its superjson
    registration.

What I could not verify: docker build itself. There is no Docker daemon
where I work. The Dockerfile changes are three targeted edits — copy
prisma.config.ts, move prisma generate after the source copy, drop the
regenerate 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 expenseFormSchema coerces
heavily — z.coerce.date(), the string-or-number amount union, several
.default()s. So what react-hook-form holds while you type genuinely is not
what the resolver returns on submit. Under zod 3 both were z.infer and the
gap was papered over with casts.

ExpenseFormValues stays z.output (so no caller changes) and a new
ExpenseFormInput is z.input, with the form typed
useForm<ExpenseFormInput, any, ExpenseFormValues>.

The measurable payoff is commit 4: nine of the twelve as any casts in
expense-form.tsx disappear
, because the string values the form assigns to
shares, originalAmount and paidFor now type-check as themselves. The two
as any in schemas.ts go too, since z.enum() takes an enum object directly:

z.enum<SplitMode, [SplitMode, ...SplitMode[]]>(Object.values(SplitMode) as any)
z.enum(SplitMode)

Being straight about the trade: the upgrade also adds seven casts, mostly
form.watch('expenseDate') as Date, because the input type of z.coerce.date()
is unknown. Those are narrow casts to real types rather than any, and they
sit exactly where raw form state is read as parsed state. Net across the file:
twelve any become three any plus seven typed casts.

The three casts left are not zod's doing and I left them alone rather than
widen the diff — SplittingOptions modelling what localStorage holds, Radix
typing onValueChange as (value: string), and an isNaN(date as any) that
really wants date.getTime().

required_error is gone in zod 4, replaced by an error callback. The callback
returns the existing message key only when the input is missing, which I checked
directly rather than trusting: parsing {} still yields titleRequired,
amountRequired and paidByRequired, and a one-character title still yields
min2 rather than the required key. So SchemaErrors lookups 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 and
openai 4 cannot resolve together — npm install fails 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.create call
type-checks against 6 unmodified.

Tailwind 4

tailwind.config.js is unchanged. v4 reads it through @config, which
keeps the CSS-variable palette, the radius scale and the accordion keyframes
working as-is rather than rewriting the theme into CSS-first @theme in the
same 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-sm now emits what v3 called shadow — a 3px blur instead of 2px.
    Three call sites: the card primitive, the recent-group card, the active tab.
    shadow-md is unchanged.
  • outline-none now sets outline-style: none, where v3 set a transparent
    2px outline. All 23 uses pair it 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, so v4 dropping the default ring from 3px to 1px does
not 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 @types resolution, and
    without the list every @types/* in the tree loads into every compilation.
    That narrowing removes the ambient Global interface src/lib/prisma.ts
    used, so it now uses globalThis.
  • TS 6 breaks npm run generate-currency-data. The new TS5011 diagnostic
    makes ts-node demand an explicit rootDir. Setting it to the project root
    fixes the script and changes nothing else, since noEmit is set and every
    included file is already under it. Confirmed by regenerating:
    currency-data.json comes back byte-identical — which incidentally
    re-verifies fix: repair main after the MYR, MKD and COP merges #557's repair.
  • @types/react 19 catches up with the react 19.2.8 already installed; they
    had been a major apart since Upgrade dependencies #479, flagged in chore: update routine deps #576.
  • lucide-react 1 dropped its brand icons, so the GitHub mark on the home
    page comes from @radix-ui/react-icons — already a direct dependency, and
    already the source of that same mark in the group list and the theme toggle.
  • jest 30 and prettier-plugin-organize-imports 4 needed nothing: 148 tests
    pass 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 from main),
and a full npm run build.

E2E: 41/41. Real Postgres 16, migrations applied from empty, the built app
under next start, driven through E2E_BASE_URL. Not scripts/e2e.sh and not
the image build — same app and same specs, but it does not exercise Docker.

What I deliberately left out

  • Newer majors that exist today: TypeScript 7, eslint 10, nanoid 6,
    openai 7, content-disposition 3, @types/node 26,
    react-intersection-observer 11, @testing-library/jest-dom 7. Every one is
    a 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.
  • The @theme migration of tailwind.config.js, per above.
  • The two moderate npm audit advisories (transitive uuid via
    next-s3-upload) are unchanged before and after this PR.

claude added 6 commits August 16, 2026 16:38
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 BastiOfBerlin left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me

@BastiOfBerlin

Copy link
Copy Markdown
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.

@BastiOfBerlin BastiOfBerlin self-assigned this Aug 17, 2026
@BastiOfBerlin
BastiOfBerlin requested a review from scastiel August 17, 2026 06:32
@BastiOfBerlin
BastiOfBerlin merged commit 6135983 into spliit-app:main Aug 17, 2026
1 check passed
@BastiOfBerlin
BastiOfBerlin deleted the up-11-major-deps branch August 17, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants