Skip to content

Commit e7c69c6

Browse files
authored
feat(upgrade): Codemods for prop removals (#7297)
1 parent 66691a6 commit e7c69c6

File tree

23 files changed

+1247
-205
lines changed

23 files changed

+1247
-205
lines changed

packages/upgrade/src/codemods/__tests__/__fixtures__/transform-clerk-react-v6.fixtures.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,18 @@ const clerk = require("@clerk/clerk-react")
117117
const clerk = require("@clerk/react")
118118
`,
119119
},
120+
{
121+
name: 'Handles directives with mixed legacy imports without double semicolons',
122+
source: `"use client";
123+
124+
import { ClerkProvider, useSignIn, useSignUp } from "@clerk/nextjs";
125+
126+
export const dynamic = "force-dynamic";
127+
`,
128+
output: `"use client";
129+
import { ClerkProvider } from "@clerk/nextjs";
130+
import { useSignIn, useSignUp } from "@clerk/nextjs/legacy";
131+
132+
export const dynamic = "force-dynamic";`,
133+
},
120134
];
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
export const fixtures = [
2+
{
3+
name: 'ClerkProvider legacy redirect props',
4+
source: `
5+
import { ClerkProvider } from '@clerk/nextjs';
6+
7+
export function App({ children }) {
8+
return (
9+
<ClerkProvider
10+
afterSignInUrl='/dashboard'
11+
afterSignUpUrl='/welcome'
12+
>
13+
{children}
14+
</ClerkProvider>
15+
);
16+
}
17+
`,
18+
output: `
19+
import { ClerkProvider } from '@clerk/nextjs';
20+
21+
export function App({ children }) {
22+
return (
23+
<ClerkProvider
24+
signInFallbackRedirectUrl='/dashboard'
25+
signUpFallbackRedirectUrl='/welcome'
26+
>
27+
{children}
28+
</ClerkProvider>
29+
);
30+
}
31+
`,
32+
},
33+
{
34+
name: 'SignIn legacy props',
35+
source: `
36+
import { SignIn as MySignIn } from '@clerk/nextjs';
37+
38+
export const Page = () => (
39+
<MySignIn
40+
afterSignInUrl='/home'
41+
afterSignUpUrl='/after-sign-up'
42+
fallbackRedirectUrl='/existing'
43+
/>
44+
);
45+
`,
46+
output: `
47+
import { SignIn as MySignIn } from '@clerk/nextjs';
48+
49+
export const Page = () => (
50+
<MySignIn
51+
signUpFallbackRedirectUrl='/after-sign-up'
52+
fallbackRedirectUrl='/existing' />
53+
);
54+
`,
55+
},
56+
{
57+
name: 'SignUp legacy props',
58+
source: `
59+
import { SignUp } from '@clerk/react';
60+
61+
export function Example() {
62+
return (
63+
<SignUp afterSignUpUrl='/done' afterSignInUrl='/in' />
64+
);
65+
}
66+
`,
67+
output: `
68+
import { SignUp } from '@clerk/react';
69+
70+
export function Example() {
71+
return (<SignUp fallbackRedirectUrl='/done' signInFallbackRedirectUrl='/in' />);
72+
}
73+
`,
74+
},
75+
{
76+
name: 'ClerkProvider redirectUrl only',
77+
source: `
78+
import { ClerkProvider } from '@clerk/react';
79+
80+
export const Provider = ({ children }) => (
81+
<ClerkProvider redirectUrl='/legacy'>{children}</ClerkProvider>
82+
);
83+
`,
84+
output: `
85+
import { ClerkProvider } from '@clerk/react';
86+
87+
export const Provider = ({ children }) => (
88+
<ClerkProvider signInFallbackRedirectUrl="/legacy" signUpFallbackRedirectUrl="/legacy">{children}</ClerkProvider>
89+
);
90+
`,
91+
},
92+
{
93+
name: 'SignIn redirectUrl only',
94+
source: `
95+
import { SignIn } from '@clerk/nextjs';
96+
97+
export const Page = () => <SignIn redirectUrl='/legacy' />;
98+
`,
99+
output: `
100+
import { SignIn } from '@clerk/nextjs';
101+
102+
export const Page = () => <SignIn fallbackRedirectUrl="/legacy" />;
103+
`,
104+
},
105+
{
106+
name: 'UserButton and organization props',
107+
source: `
108+
import { UserButton, OrganizationSwitcher, CreateOrganization } from '@clerk/react';
109+
110+
export const Actions = () => (
111+
<>
112+
<UserButton afterSignOutUrl='/bye' afterMultiSessionSingleSignOutUrl='/multi' />
113+
<OrganizationSwitcher hideSlug afterSwitchOrganizationUrl='/org' />
114+
<CreateOrganization hideSlug />
115+
</>
116+
);
117+
`,
118+
output: `
119+
import { UserButton, OrganizationSwitcher, CreateOrganization } from '@clerk/react';
120+
121+
export const Actions = () => (
122+
<>
123+
<UserButton />
124+
<OrganizationSwitcher afterSelectOrganizationUrl='/org' />
125+
<CreateOrganization />
126+
</>
127+
);
128+
`,
129+
},
130+
{
131+
name: 'Object literals and destructuring',
132+
source: `
133+
const config = {
134+
afterSignInUrl: '/one',
135+
afterSignUpUrl: '/two',
136+
activeSessions,
137+
};
138+
139+
const { afterSignInUrl, afterSignUpUrl: custom, activeSessions: current } = config;
140+
`,
141+
output: `
142+
const config = {
143+
signInFallbackRedirectUrl: '/one',
144+
signUpFallbackRedirectUrl: '/two',
145+
signedInSessions: activeSessions,
146+
};
147+
148+
const { signInFallbackRedirectUrl: afterSignInUrl, signUpFallbackRedirectUrl: custom, signedInSessions: current } = config;
149+
`,
150+
},
151+
{
152+
name: 'Member expressions and optional chaining',
153+
source: `
154+
const signInTarget = options.afterSignInUrl;
155+
const signUpTarget = options?.afterSignUpUrl;
156+
const fallback = options['afterSignInUrl'];
157+
const hasSessions = client?.activeSessions?.length > 0 && client['activeSessions'];
158+
`,
159+
output: `
160+
const signInTarget = options.signInFallbackRedirectUrl;
161+
const signUpTarget = options?.signUpFallbackRedirectUrl;
162+
const fallback = options["signInFallbackRedirectUrl"];
163+
const hasSessions = client?.signedInSessions?.length > 0 && client["signedInSessions"];
164+
`,
165+
},
166+
{
167+
name: 'setActive beforeEmit callback',
168+
source: `
169+
await setActive({
170+
session: '123',
171+
beforeEmit: handleBeforeEmit,
172+
});
173+
`,
174+
output: `
175+
await setActive({
176+
session: '123',
177+
navigate: params => handleBeforeEmit(params.session),
178+
});
179+
`,
180+
},
181+
{
182+
name: 'ClerkMiddlewareAuthObject type rename',
183+
source: `
184+
import type { ClerkMiddlewareAuthObject } from '@clerk/nextjs/server';
185+
186+
type Handler = (auth: ClerkMiddlewareAuthObject) => void;
187+
`,
188+
output: `
189+
import type { ClerkMiddlewareSessionAuthObject } from '@clerk/nextjs/server';
190+
191+
type Handler = (auth: ClerkMiddlewareSessionAuthObject) => void;
192+
`,
193+
},
194+
{
195+
name: 'Namespace import support',
196+
source: `
197+
import * as Clerk from '@clerk/nextjs';
198+
199+
export const Provider = ({ children }) => (
200+
<Clerk.ClerkProvider redirectUrl='/deep' />
201+
);
202+
`,
203+
output: `
204+
import * as Clerk from '@clerk/nextjs';
205+
206+
export const Provider = ({ children }) => (
207+
<Clerk.ClerkProvider signInFallbackRedirectUrl="/deep" signUpFallbackRedirectUrl="/deep" />
208+
);
209+
`,
210+
},
211+
];
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { applyTransform } from 'jscodeshift/dist/testUtils';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import transformer from '../transform-remove-deprecated-props.cjs';
5+
import { fixtures } from './__fixtures__/transform-remove-deprecated-props.fixtures';
6+
7+
describe('transform-remove-deprecated-props', () => {
8+
it.each(fixtures)('$name', ({ source, output }) => {
9+
const result = applyTransform(transformer, {}, { source });
10+
11+
expect(result).toEqual(output.trim());
12+
});
13+
});

packages/upgrade/src/codemods/index.js

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,51 @@ import { run } from 'jscodeshift/src/Runner.js';
66

77
const __dirname = dirname(fileURLToPath(import.meta.url));
88

9-
export async function runCodemod(transform = 'transform-async-request', glob, options) {
9+
const GLOBBY_IGNORE = [
10+
'**/*.md',
11+
'node_modules/**',
12+
'**/node_modules/**',
13+
'.git/**',
14+
'**/*.json',
15+
'package.json',
16+
'**/package.json',
17+
'package-lock.json',
18+
'**/package-lock.json',
19+
'yarn.lock',
20+
'**/yarn.lock',
21+
'pnpm-lock.yaml',
22+
'**/pnpm-lock.yaml',
23+
'yalc.lock',
24+
'**/*.(ico|png|webp|svg|gif|jpg|jpeg)+',
25+
'**/*.(mp4|mkv|wmv|m4v|mov|avi|flv|webm|flac|mka|m4a|aac|ogg)+',
26+
'**/*.(css|scss|sass|less|styl)+',
27+
];
28+
29+
export async function runCodemod(transform = 'transform-async-request', glob, options = {}) {
1030
if (!transform) {
1131
throw new Error('No transform provided');
1232
}
1333
const resolvedPath = resolve(__dirname, `${transform}.cjs`);
1434

15-
const paths = await globby(glob, {
16-
ignore: [
17-
'**/*.md',
18-
'node_modules/**',
19-
'**/node_modules/**',
20-
'.git/**',
21-
'**/*.json',
22-
'package.json',
23-
'**/package.json',
24-
'package-lock.json',
25-
'**/package-lock.json',
26-
'yarn.lock',
27-
'**/yarn.lock',
28-
'pnpm-lock.yaml',
29-
'**/pnpm-lock.yaml',
30-
'yalc.lock',
31-
'**/*.(ico|png|webp|svg|gif|jpg|jpeg)+', // common image files
32-
'**/*.(mp4|mkv|wmv|m4v|mov|avi|flv|webm|flac|mka|m4a|aac|ogg)+', // common video files] }).then(files => {
33-
'**/*.(css|scss|sass|less|styl)+', // common style files
34-
],
35+
const paths = await globby(glob, { ignore: GLOBBY_IGNORE });
36+
37+
// First pass: dry run to collect stats (jscodeshift only reports stats in dry mode)
38+
const dryResult = await run(resolvedPath, paths ?? [], {
39+
...options,
40+
dry: true,
41+
silent: true,
3542
});
3643

37-
return await run(resolvedPath, paths ?? [], {
38-
dry: false,
44+
// Second pass: apply the changes
45+
const result = await run(resolvedPath, paths ?? [], {
3946
...options,
40-
// we must silence stdout to prevent output from interfering with ink CLI
47+
dry: false,
4148
silent: true,
4249
});
50+
51+
// Merge stats from dry run into final result
52+
return {
53+
...result,
54+
stats: dryResult.stats,
55+
};
4356
}

packages/upgrade/src/codemods/transform-clerk-react-v6.cjs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,20 @@ module.exports = function transformClerkReactV6({ source }, { jscodeshift: j })
4444

4545
if (legacySpecifiers.length > 0 && nonLegacySpecifiers.length > 0) {
4646
// Mixed import: keep non-legacy on targetPackage, emit a new import for legacy hooks
47-
node.specifiers = nonLegacySpecifiers;
48-
node.source = j.literal(targetPackage);
47+
// Use replaceWith to avoid formatting issues with insertAfter
48+
const mainImport = j.importDeclaration(nonLegacySpecifiers, j.stringLiteral(targetPackage));
4949
if (importKind) {
50-
node.importKind = importKind;
50+
mainImport.importKind = importKind;
5151
}
52-
const legacyImportDecl = j.importDeclaration(legacySpecifiers, j.literal(`${targetPackage}/legacy`));
52+
// Preserve leading comments/whitespace from original import
53+
mainImport.comments = node.comments;
54+
55+
const legacyImport = j.importDeclaration(legacySpecifiers, j.stringLiteral(`${targetPackage}/legacy`));
5356
if (importKind) {
54-
legacyImportDecl.importKind = importKind;
57+
legacyImport.importKind = importKind;
5558
}
56-
j(path).insertAfter(legacyImportDecl);
59+
60+
j(path).replaceWith([mainImport, legacyImport]);
5761
dirtyFlag = true;
5862
return;
5963
}
@@ -137,7 +141,14 @@ module.exports = function transformClerkReactV6({ source }, { jscodeshift: j })
137141
});
138142
});
139143

140-
return dirtyFlag ? root.toSource() : undefined;
144+
if (!dirtyFlag) {
145+
return undefined;
146+
}
147+
148+
let result = root.toSource();
149+
// Fix double semicolons that can occur when recast reprints directive prologues (e.g., "use client";)
150+
result = result.replace(/^(['"`][^'"`]+['"`]);;/gm, '$1;');
151+
return result;
141152
};
142153

143154
module.exports.parser = 'tsx';

0 commit comments

Comments
 (0)