chore: upgrade to zod v4#4039
Conversation
🦋 Changeset detectedLatest commit: 822ea2f The changes in this PR will be included in the next version bump. This PR includes changesets to release 28 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
WalkthroughPackage manifests and overrides update Zod versions, peer ranges, and dependency placement across the workspace. Core and webapp schemas switch to explicit string-keyed records and a few field-shape changes. Webapp routes and components migrate to 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/sdk
commit: |
|
Caution Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted. Error details |
Migrate the dashboard form layer from @conform-to 0.9 to the 1.x API. conform 1.x peer-depends on zod ^3.21 || ^4, so it runs on the current zod 3 and unblocks the zod 4 upgrade (conform 0.9 cannot bundle against zod 4, which removed ZodNativeEnum, ZodEffects, and ZodPipeline).
Address CodeRabbit feedback on the conform 1.x migration: use defaultValue (not value) on hidden inputs so they stay uncontrolled, return the env-var project-lookup failure as a form-level error, join multi-message field errors when rendering, and clone alerts.emails before using it as mutable field-list state.
The schedules and branches add-on purchase modals detected conform responses with "intent" in fetcher.data, but conform v1's submission.reply() returns a status field, not intent, so lastResult was always undefined and server-side errors never rendered. Switch the guard to "status" in fetcher.data, matching the rest of the migration.
- core: forward the validated/normalized payload (parsedPayload.data) from the zod message sender instead of casting the raw input, so zod transforms, defaults, and coercions are applied on send - webapp: reword the feedback type union error to cover all feedback types, not just bug/feature
The root @conform-to/zod entry is the zod 3 implementation (it imports ZodBranded/ZodEffects from zod), so it throws at module load under zod 4.4.3, breaking the auth/invite forms and the e2e suites. Switch all 44 imports to the @conform-to/zod/v4 subpath, which targets zod 4. Also address review feedback: - concurrency: use formEnvironments.errorId (not .id) for the FormError element id to avoid a duplicate DOM id and keep aria-describedby valid - team / confirm-basic-details: pass value:false to getInputProps on the controlled number/hidden inputs so conform omits defaultValue and the inputs stay purely controlled - branches.archive: join the form.errors string[] for readable output
| <Fieldset className="max-w-full gap-y-3"> | ||
| <input value={location.pathname} {...conform.input(path, { type: "hidden" })} /> | ||
| <input value={location.pathname} {...getInputProps(fields.path, { type: "hidden" })} /> | ||
| <InputGroup className="max-w-full"> |
There was a problem hiding this comment.
🔴 Hidden form fields lose their explicit values, causing feedback submissions to redirect to an empty path
The page path for the feedback form is placed before the spread of auto-generated props (getInputProps(fields.path, { type: "hidden" }) at apps/webapp/app/components/Feedback.tsx:95), so the library's own value silently overrides it.
Impact: Feedback submissions will send an empty or stale path field instead of the current page URL, breaking the post-submit redirect.
Conform v1 getInputProps returns a value prop for hidden inputs that overrides the explicit one
In conform v0.9, conform.input(field, { type: "hidden" }) returned defaultValue, which doesn't conflict with an explicit value prop. In conform v1, getInputProps(field, { type: "hidden" }) returns a value prop (the field's current form-state value).
When the JSX is <input value={location.pathname} {...getInputProps(fields.path, { type: "hidden" })} />, the spread comes second and its value overwrites the explicit value={location.pathname}. Since no defaultValue is set in useForm for the path field, the form submits path="" instead of path="/current/page".
The correct pattern in conform v1 is shown in apps/webapp/app/routes/confirm-basic-details.tsx:315:
<input {...getInputProps(confirmEmail, { type: "hidden", value: false })} value={user.email} />— either pass { value: false } to disable auto-value, or put the explicit value after the spread.
| <InputGroup className="max-w-full"> | |
| <input {...getInputProps(fields.path, { type: "hidden", value: false })} value={location.pathname} /> |
Was this helpful? React with 👍 or 👎 to provide feedback.
| <input | ||
| value={parentEnvironment.id} | ||
| {...conform.input(parentEnvironmentId, { type: "hidden" })} | ||
| {...getInputProps(parentEnvironmentId, { type: "hidden" })} | ||
| /> | ||
| <input | ||
| value={location.pathname} | ||
| {...conform.input(failurePath, { type: "hidden" })} | ||
| {...getInputProps(failurePath, { type: "hidden" })} |
There was a problem hiding this comment.
🔴 Hidden form fields lose their explicit values, preventing new branches from being created with the correct parent environment
The parent environment ID and failure redirect path are placed before the spread of auto-generated props (getInputProps(parentEnvironmentId, { type: "hidden" }) at apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx:966-972), so the library's own empty value silently overrides them.
Impact: The create-branch form submits empty values for environment ID and redirect path, causing branch creation to fail or redirect incorrectly.
Same getInputProps hidden-input override as BUG-0001
In conform v1, getInputProps(field, { type: "hidden" }) returns a value prop. When the JSX places the explicit value BEFORE the spread, the spread's value overrides it.
Lines 966-972:
<input value={parentEnvironment.id}
{...getInputProps(parentEnvironmentId, { type: "hidden" })} />
<input value={location.pathname}
{...getInputProps(failurePath, { type: "hidden" })} />Both need either { value: false } in the options or the explicit value placed after the spread.
| <input | |
| value={parentEnvironment.id} | |
| {...conform.input(parentEnvironmentId, { type: "hidden" })} | |
| {...getInputProps(parentEnvironmentId, { type: "hidden" })} | |
| /> | |
| <input | |
| value={location.pathname} | |
| {...conform.input(failurePath, { type: "hidden" })} | |
| {...getInputProps(failurePath, { type: "hidden" })} | |
| <input | |
| {...getInputProps(parentEnvironmentId, { type: "hidden", value: false })} | |
| value={parentEnvironment.id} | |
| /> | |
| <input | |
| {...getInputProps(failurePath, { type: "hidden", value: false })} | |
| value={location.pathname} | |
| /> |
Was this helpful? React with 👍 or 👎 to provide feedback.
| <input value={environment.id} {...getInputProps(environmentId, { type: "hidden" })} /> | ||
| <input | ||
| value={`${location.pathname}${location.search}`} | ||
| {...conform.input(redirectPath, { type: "hidden" })} | ||
| {...getInputProps(redirectPath, { type: "hidden" })} | ||
| /> |
There was a problem hiding this comment.
🔴 Hidden form fields lose their explicit values, causing branch archive to fail or redirect to a wrong page
The environment ID and redirect path are placed before the spread of auto-generated props (getInputProps(environmentId, { type: "hidden" }) at apps/webapp/app/routes/resources.branches.archive.tsx:98-102), so the library's own empty value silently overrides them.
Impact: Archiving a branch submits wrong field values, causing the archive action to fail or redirect incorrectly after completion.
Same getInputProps hidden-input override as BUG-0001
Lines 98-102:
<input value={environment.id} {...getInputProps(environmentId, { type: "hidden" })} />
<input value={`${location.pathname}${location.search}`}
{...getInputProps(redirectPath, { type: "hidden" })} />Both need either { value: false } in the options or the explicit value placed after the spread.
| <input value={environment.id} {...getInputProps(environmentId, { type: "hidden" })} /> | |
| <input | |
| value={`${location.pathname}${location.search}`} | |
| {...conform.input(redirectPath, { type: "hidden" })} | |
| {...getInputProps(redirectPath, { type: "hidden" })} | |
| /> | |
| <input {...getInputProps(environmentId, { type: "hidden", value: false })} value={environment.id} /> | |
| <input | |
| {...getInputProps(redirectPath, { type: "hidden", value: false })} | |
| value={`${location.pathname}${location.search}`} | |
| /> |
Was this helpful? React with 👍 or 👎 to provide feedback.
| </InputGroup> | ||
| {canCreateV3Projects ? ( | ||
| <input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} /> | ||
| <input {...getInputProps(projectVersion, { type: "hidden" })} defaultValue={"v3"} /> |
There was a problem hiding this comment.
🔴 Project version hidden field uses defaultValue which is ignored, so new projects may be created without the correct version
The project version hidden input uses defaultValue after the spread (getInputProps(projectVersion, { type: "hidden" }) at apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx:391), but the library returns a value prop for hidden inputs, so React ignores defaultValue entirely.
Impact: New projects may be created without a version field, because the hidden input submits the field's empty form-state value instead of "v3" or "v2".
getInputProps for hidden type returns value, making defaultValue a no-op
In conform v1, getInputProps(field, { type: "hidden" }) returns a value prop from the field's form state. React ignores defaultValue when value is present on the same element.
Old code (worked because conform.input returned defaultValue, not value):
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />New code (broken because getInputProps returns value, and defaultValue is ignored):
<input {...getInputProps(projectVersion, { type: "hidden" })} defaultValue={"v3"} />Fix: use value after the spread, or pass { value: false } to opt out of auto-value.
| <input {...getInputProps(projectVersion, { type: "hidden" })} defaultValue={"v3"} /> | |
| <input {...getInputProps(projectVersion, { type: "hidden", value: false })} value={"v3"} /> |
Was this helpful? React with 👍 or 👎 to provide feedback.
| export function getFlagControlType(schema: z.ZodType): FlagControlType { | ||
| // zod v4: schema.def.type is a lowercase tag ("boolean"/"enum"/"number"/...). | ||
| const def = schema.def as { | ||
| type: string; | ||
| checks?: Array<{ _zod?: { def?: { check?: string; value?: number } } }>; | ||
| }; | ||
|
|
||
| if (typeName === "ZodBoolean") { | ||
| if (def.type === "boolean") { | ||
| return { type: "boolean" }; | ||
| } | ||
|
|
||
| if (typeName === "ZodEnum") { | ||
| return { type: "enum", options: schema._def.values as string[] }; | ||
| if (def.type === "enum") { | ||
| return { type: "enum", options: (schema as z.ZodEnum).options as string[] }; | ||
| } | ||
|
|
||
| // z.coerce.number() reports as ZodNumber; pull min/max out of its checks | ||
| // so the UI can render a constrained number input instead of free text. | ||
| if (typeName === "ZodNumber") { | ||
| const checks = (schema._def.checks ?? []) as Array<{ kind: string; value?: number }>; | ||
| const min = checks.find((c) => c.kind === "min")?.value; | ||
| const max = checks.find((c) => c.kind === "max")?.value; | ||
| // z.coerce.number() reports as "number"; pull min/max out of its v4 checks | ||
| // (each check carries `_zod.def.{check,value}`) so the UI can render a | ||
| // constrained number input instead of free text. | ||
| if (def.type === "number") { | ||
| const checks = def.checks ?? []; | ||
| const min = checks.find((c) => c._zod?.def?.check === "greater_than")?._zod?.def?.value; | ||
| const max = checks.find((c) => c._zod?.def?.check === "less_than")?._zod?.def?.value; | ||
| return { type: "number", min, max }; | ||
| } | ||
|
|
There was a problem hiding this comment.
🚩 getFlagControlType relies on undocumented zod v4 internals for check introspection
The rewritten getFlagControlType casts schema.def and inspects _zod.def.check strings like "greater_than" and "less_than" to extract min/max bounds from number schemas (apps/webapp/app/v3/featureFlags.ts:117-121). This relies on zod v4's internal check structure, which is not part of the public API. If zod v4 uses different check names (e.g., "minimum" for .min() and "maximum" for .max()), the bounds would silently resolve to undefined and the admin flag UI would show an unconstrained number input instead of 0–100. Similarly, z.coerce.boolean() and z.coerce.number() may report a different def.type than plain z.boolean() / z.number(), causing those flags to fall through to the "string" default. I could not verify the exact v4 internal names without running the code.
(Refers to lines 99-125)
Was this helpful? React with 👍 or 👎 to provide feedback.
| // send the validated/normalized output so zod transforms, defaults, and coercions are applied | ||
| await this.#sender({ type, payload: parsedPayload.data, version: "v1" }); | ||
| } catch (error) { |
There was a problem hiding this comment.
🚩 ZodMessageSender.send() now sends parsedPayload.data instead of the raw payload
In packages/core/src/v3/zodMessageHandler.ts:280-283, send() was changed from sending the raw payload to sending parsedPayload.data. This means zod transforms, defaults, and coercions are now applied before the message is sent over the wire. This is a semantic change: previously, receivers got the raw caller input; now they get the parsed/transformed output. The same change appears at line 336. This could change wire-format for messages that use transforms in their schemas. The comment says this is intentional ("send the validated/normalized output so zod transforms, defaults, and coercions are applied"). If any receiver re-parses with the same schema, the double-transform could produce different results (e.g., a coerce transform applied twice).
Was this helpful? React with 👍 or 👎 to provide feedback.
Experimental, not thoroughly tested yet.
TODO list before merging