-
Notifications
You must be signed in to change notification settings - Fork 431
feat(upgrade): add satellite auto-sync codemod for core 3 migration #7653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@clerk/upgrade': minor | ||
| --- | ||
|
|
||
| Add `transform-satellite-auto-sync` codemod for Core 3 migration that adds `satelliteAutoSync: true` wherever `isSatellite` is configured |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| export const fixtures = [ | ||
| { | ||
| name: 'JSX: isSatellite={true}', | ||
| source: ` | ||
| import { ClerkProvider } from '@clerk/nextjs'; | ||
|
|
||
| export function App({ children }) { | ||
| return ( | ||
| <ClerkProvider isSatellite={true} domain="example.com"> | ||
| {children} | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| `, | ||
| output: ` | ||
| import { ClerkProvider } from '@clerk/nextjs'; | ||
|
|
||
| export function App({ children }) { | ||
| return ( | ||
| <ClerkProvider isSatellite={true} domain="example.com" satelliteAutoSync={true}> | ||
| {children} | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'JSX: isSatellite (boolean shorthand)', | ||
| source: ` | ||
| import { ClerkProvider } from '@clerk/react'; | ||
|
|
||
| export const App = () => ( | ||
| <ClerkProvider isSatellite domain="satellite.example.com"> | ||
| <Main /> | ||
| </ClerkProvider> | ||
| ); | ||
| `, | ||
| output: ` | ||
| import { ClerkProvider } from '@clerk/react'; | ||
|
|
||
| export const App = () => ( | ||
| <ClerkProvider isSatellite domain="satellite.example.com" satelliteAutoSync={true}> | ||
| <Main /> | ||
| </ClerkProvider> | ||
| ); | ||
| `, | ||
| }, | ||
| { | ||
| name: 'JSX: isSatellite with function value', | ||
| source: ` | ||
| import { ClerkProvider } from '@clerk/nextjs'; | ||
|
|
||
| export function App({ children }) { | ||
| return ( | ||
| <ClerkProvider isSatellite={(url) => url.host === 'satellite.example.com'} domain="example.com"> | ||
| {children} | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| `, | ||
| output: ` | ||
| import { ClerkProvider } from '@clerk/nextjs'; | ||
|
|
||
| export function App({ children }) { | ||
| return ( | ||
| <ClerkProvider | ||
| isSatellite={(url) => url.host === 'satellite.example.com'} | ||
| domain="example.com" | ||
| satelliteAutoSync={true}> | ||
| {children} | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| `, | ||
| }, | ||
| { | ||
| name: 'JSX: already has satelliteAutoSync', | ||
| source: ` | ||
| import { ClerkProvider } from '@clerk/nextjs'; | ||
|
|
||
| export function App({ children }) { | ||
| return ( | ||
| <ClerkProvider isSatellite={true} satelliteAutoSync={false} domain="example.com"> | ||
| {children} | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| `, | ||
| noChange: true, | ||
| }, | ||
| { | ||
| name: 'Object: isSatellite in clerkMiddleware options', | ||
| source: ` | ||
| import { clerkMiddleware } from '@clerk/nextjs/server'; | ||
|
|
||
| export default clerkMiddleware({ | ||
| isSatellite: true, | ||
| domain: 'example.com', | ||
| }); | ||
| `, | ||
| output: ` | ||
| import { clerkMiddleware } from '@clerk/nextjs/server'; | ||
|
|
||
| export default clerkMiddleware({ | ||
| isSatellite: true, | ||
| satelliteAutoSync: true, | ||
| domain: 'example.com' | ||
| }); | ||
| `, | ||
| }, | ||
| { | ||
| name: 'Object: isSatellite in variable declaration', | ||
| source: ` | ||
| const options = { | ||
| isSatellite: true, | ||
| domain: 'satellite.example.com', | ||
| }; | ||
| `, | ||
| output: ` | ||
| const options = { | ||
| isSatellite: true, | ||
| satelliteAutoSync: true, | ||
| domain: 'satellite.example.com' | ||
| }; | ||
| `, | ||
| }, | ||
| { | ||
| name: 'Object: isSatellite with function value', | ||
| source: ` | ||
| import { clerkMiddleware } from '@clerk/nextjs/server'; | ||
|
|
||
| export default clerkMiddleware({ | ||
| isSatellite: (url) => url.host === 'satellite.example.com', | ||
| domain: 'example.com', | ||
| }); | ||
| `, | ||
| output: ` | ||
| import { clerkMiddleware } from '@clerk/nextjs/server'; | ||
|
|
||
| export default clerkMiddleware({ | ||
| isSatellite: (url) => url.host === 'satellite.example.com', | ||
| satelliteAutoSync: true, | ||
| domain: 'example.com' | ||
| }); | ||
| `, | ||
| }, | ||
| { | ||
| name: 'Object: already has satelliteAutoSync', | ||
| source: ` | ||
| const options = { | ||
| isSatellite: true, | ||
| satelliteAutoSync: false, | ||
| domain: 'example.com', | ||
| }; | ||
| `, | ||
| noChange: true, | ||
| }, | ||
| { | ||
| name: 'No isSatellite present (no changes)', | ||
| source: ` | ||
| import { ClerkProvider } from '@clerk/nextjs'; | ||
|
|
||
| export function App({ children }) { | ||
| return ( | ||
| <ClerkProvider domain="example.com"> | ||
| {children} | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| `, | ||
| noChange: true, | ||
| }, | ||
| ]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { applyTransform } from 'jscodeshift/dist/testUtils'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import transformer from '../transform-satellite-auto-sync.cjs'; | ||
| import { fixtures } from './__fixtures__/transform-satellite-auto-sync.fixtures'; | ||
|
|
||
| describe('transform-satellite-auto-sync', () => { | ||
| it.each(fixtures)('$name', ({ source, output, noChange }) => { | ||
| const result = applyTransform(transformer, {}, { source }); | ||
|
|
||
| if (noChange) { | ||
| expect(result).toEqual(''); | ||
| } else { | ||
| expect(result).toEqual(output.trim()); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| module.exports = function transformSatelliteAutoSync({ source }, { jscodeshift: j }) { | ||
| const root = j(source); | ||
| let dirty = false; | ||
|
|
||
| // Handle JSX attributes: <Component isSatellite /> → <Component isSatellite satelliteAutoSync={true} /> | ||
| root.find(j.JSXOpeningElement).forEach(path => { | ||
| const { attributes } = path.node; | ||
| if (!attributes) { | ||
| return; | ||
| } | ||
|
|
||
| const hasIsSatellite = attributes.some(attr => isJsxAttrNamed(attr, 'isSatellite')); | ||
| if (!hasIsSatellite) { | ||
| return; | ||
| } | ||
|
|
||
| const hasAutoSync = attributes.some(attr => isJsxAttrNamed(attr, 'satelliteAutoSync')); | ||
| if (hasAutoSync) { | ||
| return; | ||
| } | ||
|
|
||
| const autoSyncAttr = j.jsxAttribute( | ||
| j.jsxIdentifier('satelliteAutoSync'), | ||
| j.jsxExpressionContainer(j.booleanLiteral(true)), | ||
| ); | ||
| attributes.push(autoSyncAttr); | ||
| dirty = true; | ||
| }); | ||
|
|
||
| // Handle object properties: { isSatellite: true } → { isSatellite: true, satelliteAutoSync: true } | ||
| root.find(j.ObjectExpression).forEach(path => { | ||
| const { properties } = path.node; | ||
|
|
||
| const hasIsSatellite = properties.some(prop => isObjectPropertyNamed(prop, 'isSatellite')); | ||
| if (!hasIsSatellite) { | ||
| return; | ||
| } | ||
|
|
||
| const hasAutoSync = properties.some(prop => isObjectPropertyNamed(prop, 'satelliteAutoSync')); | ||
| if (hasAutoSync) { | ||
| return; | ||
| } | ||
|
|
||
| const isSatelliteIndex = properties.findIndex(prop => isObjectPropertyNamed(prop, 'isSatellite')); | ||
| const autoSyncProp = j.objectProperty(j.identifier('satelliteAutoSync'), j.booleanLiteral(true)); | ||
| properties.splice(isSatelliteIndex + 1, 0, autoSyncProp); | ||
| dirty = true; | ||
|
Comment on lines
+6
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n packages/upgrade/src/codemods/transform-satellite-auto-sync.cjsRepository: clerk/javascript Length of output: 2830 🏁 Script executed: cat > /tmp/test_spreads.js << 'EOF'
// Test JSX spread override semantics
// In JSX: <Component isSatellite {...obj} satelliteAutoSync={true} />
// Does the explicit attribute AFTER the spread override the spread's value?
// For objects: { isSatellite: true, ...obj, satelliteAutoSync: true }
// Does the property AFTER the spread override?
// Let's verify with a simple example
const obj = { satelliteAutoSync: false, other: 123 };
// Object property override
const result1 = { isSatellite: true, ...obj, satelliteAutoSync: true };
console.log("Object with spread BEFORE override:");
console.log(" Input: { isSatellite: true, ...{satelliteAutoSync: false}, satelliteAutoSync: true }");
console.log(" Result satelliteAutoSync:", result1.satelliteAutoSync);
console.log(" Spread is overridden by later property? ", result1.satelliteAutoSync === true ? "YES" : "NO");
const result2 = { isSatellite: true, satelliteAutoSync: true, ...obj };
console.log("\nObject with spread AFTER property:");
console.log(" Input: { isSatellite: true, satelliteAutoSync: true, ...{satelliteAutoSync: false} }");
console.log(" Result satelliteAutoSync:", result2.satelliteAutoSync);
console.log(" Spread overrides property? ", result2.satelliteAutoSync === false ? "YES (PROBLEM)" : "NO");
EOF
node /tmp/test_spreads.jsRepository: clerk/javascript Length of output: 463 🏁 Script executed: cat > /tmp/analyze_code.js << 'EOF'
// Simulate the current codemod logic with different spread positions
// For objects: current code does:
// const isSatelliteIndex = properties.findIndex(prop => isObjectPropertyNamed(prop, 'isSatellite'));
// properties.splice(isSatelliteIndex + 1, 0, autoSyncProp);
console.log("=== Object property insertion analysis ===\n");
// Case 1: Spread BEFORE isSatellite
console.log("Case 1: { ...obj, isSatellite: true }");
console.log(" isSatelliteIndex = 1");
console.log(" splice(1 + 1, 0, autoSyncProp)");
console.log(" Result: [ ...obj, isSatellite, satelliteAutoSync, ... ]");
console.log(" Override risk: NO (property after spread wins)\n");
// Case 2: Spread AFTER isSatellite
console.log("Case 2: { isSatellite: true, ...obj }");
console.log(" isSatelliteIndex = 0");
console.log(" splice(0 + 1, 0, autoSyncProp)");
console.log(" Result: [ isSatellite, satelliteAutoSync, ...obj, ... ]");
console.log(" Override risk: YES (spread after property wins)\n");
// For JSX: current code does:
// attributes.push(autoSyncAttr);
console.log("=== JSX attribute append analysis ===\n");
console.log("JSX appends attribute at END (last position)");
console.log(" After push: [ ...any, isSatellite, ...any, satelliteAutoSync=true ]");
console.log(" Key point: Is there ANYTHING after the push?");
console.log(" If spread is already last: spreads at END would follow the attribute");
console.log(" But push adds to end, so new attr is always last");
console.log(" Override risk: Need to check JSX semantics for attributes after spreads\n");
EOF
node /tmp/analyze_code.jsRepository: clerk/javascript Length of output: 906 🌐 Web query:
💡 Result: Short answer: later attributes win — an explicit prop after a spread overrides the same key from the spread. Multiple spreads follow the same last-wins rule. Why: JSX is transpiled into a props merge (like Object.assign/_extends) where sources are applied in source order; later sources overwrite earlier ones. Example:
Sources: 🏁 Script executed: cat > /tmp/verify_risk.js << 'EOF'
console.log("=== RISK ANALYSIS ===\n");
console.log("OBJECT CASE: Properties order matters\n");
// Case A: Spread BEFORE isSatellite
console.log("A. { ...obj (with satelliteAutoSync: false), isSatellite: true }");
console.log(" isSatelliteIndex = 1");
console.log(" splice(1+1=2, 0, satelliteAutoSync=true)");
console.log(" Result: [ ...obj, isSatellite, satelliteAutoSync, ] ");
console.log(" Outcome: satelliteAutoSync is AFTER spread → WINS (true is correct)");
console.log(" Risk: NONE\n");
// Case B: Spread AFTER isSatellite
console.log("B. { isSatellite: true, ...obj (with satelliteAutoSync: false) }");
console.log(" isSatelliteIndex = 0");
console.log(" splice(0+1=1, 0, satelliteAutoSync=true)");
console.log(" Result: [ isSatellite, satelliteAutoSync, ...obj, ]");
console.log(" Outcome: satelliteAutoSync is BEFORE spread → LOSES (false overrides true)");
console.log(" Risk: CRITICAL! User's satelliteAutoSync:false is lost\n");
console.log("JSX CASE: Attributes order matters\n");
console.log("C. <Comp isSatellite {...obj (with satelliteAutoSync: false)} />");
console.log(" attributes.push() appends to end");
console.log(" Result: [ isSatellite, ...spread, satelliteAutoSync=true ]");
console.log(" Transpiles to: { isSatellite, ...obj, satelliteAutoSync: true }");
console.log(" Outcome: satelliteAutoSync is AFTER spread → WINS (true is correct)");
console.log(" Risk: NONE\n");
console.log("D. <Comp {...obj (with satelliteAutoSync: false)} isSatellite />");
console.log(" attributes.push() appends to end");
console.log(" Result: [ ...spread, isSatellite, satelliteAutoSync=true ]");
console.log(" Transpiles to: { ...obj, isSatellite, satelliteAutoSync: true }");
console.log(" Outcome: satelliteAutoSync is AFTER spread → WINS (true is correct)");
console.log(" Risk: NONE\n");
console.log("CONCLUSION:");
console.log("- JSX: SAFE (push always appends to end)");
console.log("- Objects: RISKY when spread comes AFTER isSatellite");
EOF
node /tmp/verify_risk.jsRepository: clerk/javascript Length of output: 1495 🏁 Script executed: cat > /tmp/check_hasAutoSync.js << 'EOF'
// Check if hasAutoSync guards against the spread override risk
console.log("=== hasAutoSync check analysis ===\n");
console.log("Code: const hasAutoSync = properties.some(prop => isObjectPropertyNamed(prop, 'satelliteAutoSync'));");
console.log("This checks if satelliteAutoSync exists ANYWHERE in properties.\n");
console.log("Risk scenario:");
console.log("{ isSatellite: true, ...obj } // obj = { satelliteAutoSync: false }");
console.log("hasAutoSync checks the actual properties array");
console.log("properties = [ {isSatellite}, {spread} ]");
console.log("The spread node itself is type: SpreadElement/SpreadProperty");
console.log("hasAutoSync looks for properties with name 'satelliteAutoSync'");
console.log("The spread doesn't have a 'name', so it's not detected by isObjectPropertyNamed");
console.log("Result: hasAutoSync = false (spread content is not inspected)");
console.log("Code proceeds to add the property at wrong position");
console.log("Conclusion: hasAutoSync does NOT protect against spread override risk");
EOF
node /tmp/check_hasAutoSync.jsRepository: clerk/javascript Length of output: 821 Guard against spreads positioned after For JSX, the current For objects, skip the transform if a spread appears after 🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nikosdouvlis This is an interesting point but in reverse. We likely want the spread to win so any explicit setting is respected. Safest would be to place the new prop/property before any spreads, but bar that maybe we at least want the component and object transform to behave the same? Very much a NIT though.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }); | ||
|
|
||
| return dirty ? root.toSource() : undefined; | ||
| }; | ||
|
|
||
| module.exports.parser = 'tsx'; | ||
|
|
||
| function isJsxAttrNamed(attribute, name) { | ||
| return attribute && attribute.type === 'JSXAttribute' && attribute.name && attribute.name.name === name; | ||
| } | ||
|
|
||
| function isObjectPropertyNamed(prop, name) { | ||
| if (!prop || (prop.type !== 'ObjectProperty' && prop.type !== 'Property')) { | ||
| return false; | ||
| } | ||
| const { key } = prop; | ||
| if (!key) { | ||
| return false; | ||
| } | ||
| if (key.type === 'Identifier') { | ||
| return key.name === name; | ||
| } | ||
| if (key.type === 'StringLiteral' || key.type === 'Literal') { | ||
| return key.value === name; | ||
| } | ||
| return false; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is adding
satelliteAutoSyncto every component and object that has aisSatelliteoverly broad, given there might be unrelated components/objects that also has it?I get this is tricky to narrow down given props and objects can be passed around, is the idea that we'd rather have false positives than miss some?
Seems like an edge case, should still be safe and most people review the output after running a codemod anyway so I'm very fine with this, just wanted to double check this is the intent?