Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/satellite-auto-sync-codemod.md
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());
}
});
});
74 changes: 74 additions & 0 deletions packages/upgrade/src/codemods/transform-satellite-auto-sync.cjs
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
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n packages/upgrade/src/codemods/transform-satellite-auto-sync.cjs

Repository: 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.js

Repository: 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.js

Repository: clerk/javascript

Length of output: 906


🌐 Web query:

JSX spread attributes override semantics later explicit attributes

💡 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:

  • <Comp {...a} x="2" /> → x === "2"
  • <Comp x="1" {...a} /> → x === a.x (spread wins)
    (Refs: React docs and explanation). [1][2]

Sources:
[1] React — JSX In Depth (spread attributes, order/usage).
[2] Explanation / examples showing "later attributes override previous ones".


🏁 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.js

Repository: 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.js

Repository: clerk/javascript

Length of output: 821


Guard against spreads positioned after isSatellite in objects.
When an object is { isSatellite: true, ...obj } where obj contains satelliteAutoSync, the code inserts the new property at isSatelliteIndex + 1, placing it before the spread. This allows the spread's value to override the inserted property, silently reversing user intent. The hasAutoSync check doesn't detect spreads (only named properties), so this case isn't skipped.

For JSX, the current attributes.push() is safe—appending ensures the new attribute is always last, so it wins over any preceding spread.

For objects, skip the transform if a spread appears after isSatellite, or insert before the first spread so explicit values are not overridden.

🤖 Prompt for AI Agents
In `@packages/upgrade/src/codemods/transform-satellite-auto-sync.cjs` around lines
6 - 47, The object transform in root.find(j.ObjectExpression) currently finds
isSatelliteIndex and unconditionally inserts satelliteAutoSync at
isSatelliteIndex + 1, which can be shadowed by a following spread; update the
logic to detect any SpreadElement/SpreadProperty (AST node representing ...obj)
after the isSatellite property: if a spread exists after isSatellite, either
skip the transform or insert the new j.objectProperty('satelliteAutoSync',
j.booleanLiteral(true)) before the first spread instead of at isSatelliteIndex +
1; adjust the check that uses isObjectPropertyNamed(...) and the insertion that
calls properties.splice(...) to use the computed safe insert index (or bail out)
so explicit property values are not overridden by spreads.

});

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;
}
1 change: 1 addition & 0 deletions packages/upgrade/src/versions/core-3/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ export default {
{ name: 'transform-clerk-provider-inside-body', packages: ['nextjs'] },
// Migrate @clerk/react-router/api.server → @clerk/react-router/server
{ name: 'transform-react-router-api-server', packages: ['react-router'] },
{ name: 'transform-satellite-auto-sync', packages: ['nextjs', 'react', 'expo', 'astro', 'tanstack-react-start'] },
],
};
Loading