Skip to content

Commit 0aca4e8

Browse files
committed
fix: address review feedback — concurrent-safe consumeAttachments & scope-aware codemod
1. consumeAttachments: read from a ref mirror instead of side-effecting out of a setState updater, avoiding reliance on synchronous updater execution under React concurrent mode. 2. Codemod: skip declaration positions (variable, function, class, type, interface) and non-reference positions (object keys, member accesses). When a local declaration shadows the import name, only rename unambiguous type-position references to avoid corrupting unrelated code.
1 parent 136bfc1 commit 0aca4e8

3 files changed

Lines changed: 96 additions & 21 deletions

File tree

codemods/__tests__/migrate-attachments.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,38 @@ import { ImageUploadQueue } from "some-other-package";
184184
expect(output).toContain("ImageUploadQueue");
185185
expect(output).not.toContain("AttachmentQueue");
186186
});
187+
188+
it("does not rename local variables that shadow the import name", () => {
189+
const input = `
190+
import type { ImageUpload } from "@copilotkit/react-ui";
191+
const x: ImageUpload = {} as any;
192+
const ImageUpload = "unrelated local variable";
193+
console.log(ImageUpload);
194+
`;
195+
const output = run(input);
196+
// Import and type reference should be renamed
197+
expect(output).toContain("import type { Attachment }");
198+
expect(output).toContain("const x: Attachment");
199+
// Local variable declaration and its reference should NOT be renamed
200+
expect(output).toContain('const ImageUpload = "unrelated local variable"');
201+
expect(output).toContain("console.log(ImageUpload)");
202+
});
203+
204+
it("does not rename object property keys or member expressions", () => {
205+
const input = `
206+
import type { ImageUpload } from "@copilotkit/react-ui";
207+
const x: ImageUpload = {} as any;
208+
const config = { ImageUpload: true };
209+
const val = obj.ImageUpload;
210+
`;
211+
const output = run(input);
212+
// Import and type reference should be renamed
213+
expect(output).toContain("import type { Attachment }");
214+
expect(output).toContain("const x: Attachment");
215+
// Object key and member access should NOT be renamed
216+
expect(output).toContain("{ ImageUpload: true }");
217+
expect(output).toContain("obj.ImageUpload");
218+
});
187219
});
188220

189221
// -----------------------------------------------------------------------

codemods/migrate-attachments.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,71 @@ export default function transform(file: FileInfo, api: API) {
6666
// Rename the imported identifier
6767
imported.name = newName;
6868

69-
// NOTE: This rename is not scope-aware — it renames ALL identifiers with the
70-
// matching name in the file, not just references to the import. In practice
71-
// this is safe because ImageUploadQueue/ImageUpload are unlikely local variable
72-
// names, but a scope-aware rename (via jscodeshift's scope utilities) would be
73-
// more correct.
74-
7569
// If the local name matched the old imported name (not aliased),
76-
// update all references in the file to use the new name.
70+
// update references in the file to use the new name.
71+
//
72+
// To avoid corrupting unrelated code, we check if any local
73+
// declaration (variable, function, class) shadows the imported
74+
// name. If so, we only rename type-position references (which
75+
// unambiguously refer to the type import) and leave value-position
76+
// references alone since they may refer to the local binding.
7777
if (!isAliased) {
78+
const hasShadow =
79+
root.find(j.VariableDeclarator, { id: { type: "Identifier", name: localName } }).length > 0 ||
80+
root.find(j.FunctionDeclaration, { id: { type: "Identifier", name: localName } }).length > 0 ||
81+
root.find(j.ClassDeclaration, { id: { type: "Identifier", name: localName } }).length > 0;
82+
7883
root.find(j.Identifier, { name: localName }).forEach((idPath) => {
7984
// Skip the import specifier itself — already renamed above
8085
if (idPath.parent.node === spec) return;
81-
idPath.node.name = newName;
82-
});
8386

84-
// Also update JSX element names (opening + closing tags)
85-
root.find(j.JSXIdentifier, { name: localName }).forEach((idPath) => {
87+
const parent = idPath.parent.node;
88+
89+
// Skip declaration positions — these define new bindings
90+
if (parent.type === "VariableDeclarator" && parent.id === idPath.node) return;
91+
if (parent.type === "FunctionDeclaration" && parent.id === idPath.node) return;
92+
if (parent.type === "ClassDeclaration" && parent.id === idPath.node) return;
93+
if (parent.type === "TSTypeAliasDeclaration" && parent.id === idPath.node) return;
94+
if (parent.type === "TSInterfaceDeclaration" && parent.id === idPath.node) return;
95+
96+
// Skip non-computed object property keys and member expression properties
97+
if (
98+
(parent.type === "Property" || parent.type === "ObjectProperty") &&
99+
parent.key === idPath.node &&
100+
!parent.computed
101+
) return;
102+
if (
103+
parent.type === "MemberExpression" &&
104+
parent.property === idPath.node &&
105+
!parent.computed
106+
) return;
107+
108+
// Skip import specifiers from other packages
109+
if (
110+
parent.type === "ImportSpecifier" &&
111+
idPath.parent.parent?.node !== path.node
112+
) return;
113+
114+
// If a local declaration shadows this name, only rename
115+
// unambiguous type-position references (e.g. type annotations)
116+
if (hasShadow) {
117+
const isTypePosition =
118+
parent.type === "TSTypeReference" ||
119+
parent.type === "TSTypeAnnotation" ||
120+
parent.type === "TSTypeQuery";
121+
if (!isTypePosition) return;
122+
}
123+
86124
idPath.node.name = newName;
87125
});
88126

127+
// Only rename JSX identifiers if there's no shadow
128+
if (!hasShadow) {
129+
root.find(j.JSXIdentifier, { name: localName }).forEach((idPath) => {
130+
idPath.node.name = newName;
131+
});
132+
}
133+
89134
if (spec.local) {
90135
spec.local.name = newName;
91136
}

packages/react-core/src/v2/hooks/use-attachments.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ export function useAttachments({
6060
const fileInputRef = useRef<HTMLInputElement>(null);
6161
const containerRef = useRef<HTMLDivElement>(null);
6262

63-
// Keep a ref to the latest config so stable callbacks can read current
64-
// values without appearing in dependency arrays.
63+
// Keep refs to the latest values so stable callbacks can read current
64+
// state without appearing in dependency arrays.
6565
const configRef = useRef(config);
6666
configRef.current = config;
67+
const attachmentsRef = useRef<Attachment[]>([]);
68+
attachmentsRef.current = attachments;
6769

6870
// Stable processFiles — reads config from ref, never changes identity
6971
const processFiles = useCallback(async (files: File[]) => {
@@ -241,14 +243,10 @@ export function useAttachments({
241243
}, []);
242244

243245
const consumeAttachments = useCallback(() => {
244-
let ready: Attachment[] = [];
245-
setAttachments((prev) => {
246-
ready = prev.filter((a) => a.status === "ready");
247-
if (ready.length === 0) return prev;
248-
const remaining = prev.filter((a) => a.status !== "ready");
249-
return remaining;
250-
});
251-
if (ready.length > 0 && fileInputRef.current) {
246+
const ready = attachmentsRef.current.filter((a) => a.status === "ready");
247+
if (ready.length === 0) return ready;
248+
setAttachments((prev) => prev.filter((a) => a.status !== "ready"));
249+
if (fileInputRef.current) {
252250
fileInputRef.current.value = "";
253251
}
254252
return ready;

0 commit comments

Comments
 (0)