Skip to content

Commit b1839b2

Browse files
committed
fix(schema): replace Zod deepPartial with manual recursive partial for v4 compatibility
Replace the deprecated `.deepPartial()` method with manual recursive `.partial()` application to support both Zod v3 and Zod v4. The `.deepPartial()` method was removed in Zod v4, causing build failures when projects use Zod v4 with the shadcn CLI. Solution: - Extract nested schemas (tailwindConfigSchema, aliasesSchema) as reusable building blocks - Create rawConfigSchemaDeepPartial that applies .partial() at each nesting level - Update both callsites in registry/schema.ts and commands/init.ts This change maintains full backward compatibility with Zod v3 while enabling forward compatibility with Zod v4, without duplicating validation rules. Added comprehensive tests to validate deep partial behavior with real-world registry:base configurations. Related to: https://zod.dev/v4/changelog#drops-deeppartial
1 parent 8fcfc56 commit b1839b2

File tree

3 files changed

+132
-18
lines changed

3 files changed

+132
-18
lines changed

packages/shadcn/src/commands/init.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
1010
import { configWithDefaults } from "@/src/registry/config"
1111
import { BASE_COLORS, BUILTIN_REGISTRIES } from "@/src/registry/constants"
1212
import { clearRegistryContext } from "@/src/registry/context"
13-
import { rawConfigSchema } from "@/src/schema"
13+
import { rawConfigSchema, rawConfigSchemaDeepPartial } from "@/src/schema"
1414
import { addComponents } from "@/src/utils/add-components"
1515
import { TEMPLATES, createProject } from "@/src/utils/create-project"
1616
import { loadEnvFiles } from "@/src/utils/env-loader"
@@ -105,7 +105,7 @@ export const initOptionsSchema = z.object({
105105
),
106106
baseStyle: z.boolean(),
107107
// Config from registry:base item to merge into components.json.
108-
registryBaseConfig: rawConfigSchema.deepPartial().optional(),
108+
registryBaseConfig: rawConfigSchemaDeepPartial.optional(),
109109
})
110110

111111
export const init = new Command()

packages/shadcn/src/registry/schema.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest"
22

3-
import { registryConfigSchema } from "./schema"
3+
import {
4+
rawConfigSchema,
5+
rawConfigSchemaDeepPartial,
6+
registryConfigSchema,
7+
} from "./schema"
48

59
describe("registryConfigSchema", () => {
610
it("should accept valid registry names starting with @", () => {
@@ -47,3 +51,91 @@ describe("registryConfigSchema", () => {
4751
}
4852
})
4953
})
54+
55+
describe("rawConfigSchema", () => {
56+
it("should require aliases field", () => {
57+
const config = {
58+
style: "vega",
59+
tailwind: {
60+
css: "./app/globals.css",
61+
baseColor: "neutral",
62+
},
63+
// Missing aliases - should fail
64+
}
65+
66+
const result = rawConfigSchema.safeParse(config)
67+
expect(result.success).toBe(false)
68+
})
69+
70+
it("should accept valid config with all required fields", () => {
71+
const validConfig = {
72+
style: "vega",
73+
tailwind: {
74+
css: "./app/globals.css",
75+
baseColor: "neutral",
76+
},
77+
aliases: {
78+
components: "@/components",
79+
utils: "@/lib/utils",
80+
},
81+
}
82+
83+
const result = rawConfigSchema.safeParse(validConfig)
84+
expect(result.success).toBe(true)
85+
})
86+
})
87+
88+
describe("rawConfigSchemaDeepPartial", () => {
89+
it("should allow omitting aliases entirely", () => {
90+
const partialConfig = {
91+
style: "vega",
92+
tailwind: {
93+
baseColor: "neutral",
94+
// Missing css - OK with deep partial
95+
},
96+
// Missing aliases - OK with deep partial
97+
}
98+
99+
const result = rawConfigSchemaDeepPartial.safeParse(partialConfig)
100+
expect(result.success).toBe(true)
101+
})
102+
103+
it("should allow partial tailwind config", () => {
104+
const partialConfig = {
105+
style: "vega",
106+
tailwind: {
107+
baseColor: "neutral",
108+
// Missing css - OK with deep partial
109+
},
110+
aliases: {
111+
components: "@/components",
112+
// Missing utils - OK with deep partial
113+
},
114+
}
115+
116+
const result = rawConfigSchemaDeepPartial.safeParse(partialConfig)
117+
expect(result.success).toBe(true)
118+
})
119+
120+
it("should validate real-world registry:base config", () => {
121+
// Example from apps/v4/registry/config.ts
122+
const realWorldConfig = {
123+
style: "radix-vega",
124+
iconLibrary: "lucide",
125+
menuColor: "default",
126+
menuAccent: "subtle",
127+
tailwind: {
128+
baseColor: "neutral",
129+
},
130+
}
131+
132+
const result = rawConfigSchemaDeepPartial.optional().safeParse(realWorldConfig)
133+
expect(result.success).toBe(true)
134+
expect(result.data).toEqual(realWorldConfig)
135+
})
136+
137+
it("should allow empty config", () => {
138+
const result = rawConfigSchemaDeepPartial.safeParse({})
139+
expect(result.success).toBe(true)
140+
})
141+
})

packages/shadcn/src/registry/schema.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,31 +25,53 @@ export const registryConfigSchema = z.record(
2525
registryConfigItemSchema
2626
)
2727

28+
// Extracted to support deep partial in Zod v3 and v4
29+
const tailwindConfigSchema = z.object({
30+
config: z.string().optional(),
31+
css: z.string(),
32+
baseColor: z.string(),
33+
cssVariables: z.boolean().default(true),
34+
prefix: z.string().default("").optional(),
35+
})
36+
37+
const aliasesSchema = z.object({
38+
components: z.string(),
39+
utils: z.string(),
40+
ui: z.string().optional(),
41+
lib: z.string().optional(),
42+
hooks: z.string().optional(),
43+
})
44+
2845
export const rawConfigSchema = z
2946
.object({
3047
$schema: z.string().optional(),
3148
style: z.string(),
3249
rsc: z.coerce.boolean().default(false),
3350
tsx: z.coerce.boolean().default(true),
34-
tailwind: z.object({
35-
config: z.string().optional(),
36-
css: z.string(),
37-
baseColor: z.string(),
38-
cssVariables: z.boolean().default(true),
39-
prefix: z.string().default("").optional(),
40-
}),
51+
tailwind: tailwindConfigSchema,
4152
iconLibrary: z.string().optional(),
4253
menuColor: z.enum(["default", "inverted"]).default("default").optional(),
4354
menuAccent: z.enum(["subtle", "bold"]).default("subtle").optional(),
44-
aliases: z.object({
45-
components: z.string(),
46-
utils: z.string(),
47-
ui: z.string().optional(),
48-
lib: z.string().optional(),
49-
hooks: z.string().optional(),
50-
}),
55+
aliases: aliasesSchema,
56+
registries: registryConfigSchema.optional(),
57+
})
58+
.strict()
59+
60+
// Deep partial version for registry:base config field
61+
export const rawConfigSchemaDeepPartial = z
62+
.object({
63+
$schema: z.string().optional(),
64+
style: z.string(),
65+
rsc: z.coerce.boolean().default(false),
66+
tsx: z.coerce.boolean().default(true),
67+
tailwind: tailwindConfigSchema.partial(),
68+
iconLibrary: z.string().optional(),
69+
menuColor: z.enum(["default", "inverted"]).default("default").optional(),
70+
menuAccent: z.enum(["subtle", "bold"]).default("subtle").optional(),
71+
aliases: aliasesSchema.partial(),
5172
registries: registryConfigSchema.optional(),
5273
})
74+
.partial()
5375
.strict()
5476

5577
export const configSchema = rawConfigSchema.extend({
@@ -168,7 +190,7 @@ export const registryItemCommonSchema = z.object({
168190
export const registryItemSchema = z.discriminatedUnion("type", [
169191
registryItemCommonSchema.extend({
170192
type: z.literal("registry:base"),
171-
config: rawConfigSchema.deepPartial().optional(),
193+
config: rawConfigSchemaDeepPartial.optional(),
172194
}),
173195
registryItemCommonSchema.extend({
174196
type: z.literal("registry:font"),

0 commit comments

Comments
 (0)